#!/usr/bin/env node import { createWriteStream } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { ApiClient, ApiError, type TokenInfo } from '../cli/client.ts'; import { readHidden, readPiped } from '../cli/prompt.ts'; import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts'; import { formatBytes } from '../core/extract.ts'; import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts'; import { noteAdd, noteImport, noteList, noteShow } from '../cli/notes.ts'; import { sync, type SyncEvent } from '../cli/sync.ts'; /** * `schulcloud` — the command-line front end. * * Speaks only to the schulcloud-mcp server, never to Schulcloud: the Pi holds * the one Schulcloud session and keeps it alive, so this machine stores nothing * but a bearer token. See docs/CLI.md. */ const USAGE = `schulcloud — browse and mirror your Schulcloud files schulcloud login --server --token [--dir ] schulcloud status schulcloud ls [--course ] [--files] [--long] schulcloud get [--out ] schulcloud sync [--dry-run] [--full] [--prune] [--dir ] [--jobs ] schulcloud refresh [--course ] [--force] schulcloud token when the server's Schulcloud token expires schulcloud token set hand the server a fresh one (paste, or pipe it in) The file manager ("Dateien") — /my, /courses/, /teams/, /shared: schulcloud fs ls [path] [--long] schulcloud fs tree [path] [--depth ] [--max-folders ] schulcloud fs find [--path ] [--type file|folder] [--long] schulcloud fs get [--out ] [--force] [--jobs ] fs get downloads a file, or a folder with everything below it. Names may contain "/" and still resolve; any path segment can also be an id from "fs ls --long". Your own lesson notes — Markdown files the agents read as context: schulcloud note ls [--subject ] [--since ] [--until ] [--long] schulcloud note show schulcloud note add --title [--subject <name>] [--date <date>] [--tags a,b] [--append] text on stdin, or --text schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run] note import takes the file scripts/export-apple-notes.js writes on a Mac; see docs/NOTES.md. --out writes the Markdown locally instead of sending it. --course takes a course or a room id: rooms ("Räume") mirror alongside courses and their files sit under the room's name. Options are also read from SCHULCLOUD_SERVER, SCHULCLOUD_TOKEN and SCHULCLOUD_SYNC_DIR. Config file: ${configPath()} `; async function main(argv: string[]): Promise<number> { // A leading flag means no command was given: `schulcloud --help` must not be // read as a command called "--help". const hasCommand = argv[0] !== undefined && !argv[0].startsWith('-'); const command = hasCommand ? argv[0] : undefined; const flags = parseFlags(hasCommand ? argv.slice(1) : argv); if (!command || command === 'help' || flags.help || flags.h) { process.stdout.write(USAGE); return 0; } switch (command) { case 'login': return login(flags); case 'status': return status(); case 'ls': return list(flags); case 'get': return get(flags); case 'sync': return runSync(flags); case 'refresh': return refresh(flags); case 'fs': return fileManager(flags); case 'note': case 'notes': return notes(flags); case 'token': return token(flags); default: process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`); return 2; } } async function login(flags: Flags): Promise<number> { const server = String(flags.server ?? ''); const token = String(flags.token ?? ''); if (!server || !token) { process.stderr.write('login needs --server and --token.\n'); return 2; } const syncDir = flags.dir ? resolve(String(flags.dir)) : defaultSyncDir(); const config = { server: server.replace(/\/+$/, ''), token, syncDir }; // Verify before saving, so a typo fails now rather than on first real use. try { await new ApiClient(config).status(); } catch (error) { process.stderr.write(`Could not reach the server: ${(error as Error).message}\n`); return 1; } const path = await saveCliConfig(config); process.stdout.write(`Saved ${path}\n server: ${config.server}\n sync dir: ${config.syncDir}\n`); return 0; } async function status(): Promise<number> { const api = new ApiClient(await loadCliConfig()); const info = (await api.status()) as { crawlId?: number; crawledAt?: string; nodes?: number; files?: number; extracted?: number; mirrored?: number; indexer?: { running?: boolean; scope?: string } | null; }; if (info.crawlId === undefined) { process.stdout.write('The server index is empty. Run: schulcloud refresh\n'); return 0; } const age = info.crawledAt ? Math.round((Date.now() - new Date(info.crawledAt).getTime()) / 60_000) : undefined; process.stdout.write( `generation ${info.crawlId}${age !== undefined ? ` — crawled ${age} min ago` : ''}\n` + ` ${info.nodes} items, ${info.files} files\n` + ` ${info.extracted} with extracted text, ${info.mirrored} mirrored on the server\n` + (info.indexer?.running ? ` a re-crawl is running (${info.indexer.scope})\n` : ''), ); return 0; } async function list(flags: Flags): Promise<number> { const api = new ApiClient(await loadCliConfig()); const manifest = await api.manifest(); let entries = manifest.entries.filter((entry) => entry.status !== 'removed'); // A room id works here too: the manifest's courseId is the container id, and // since rooms were added that container can be a room. if (flags.course) entries = entries.filter((entry) => entry.courseId === flags.course); if (entries.length === 0) { process.stdout.write('No files.\n'); return 0; } entries.sort((a, b) => a.path.localeCompare(b.path)); for (const entry of entries) { if (flags.long) { process.stdout.write(`${entry.fileId} ${String(formatBytes(entry.size)).padStart(9)} ${entry.path}\n`); } else { process.stdout.write(`${entry.path}\n`); } } process.stderr.write(`\n${entries.length} file(s), generation ${manifest.cursor}\n`); return 0; } async function get(flags: Flags): Promise<number> { const fileId = String(flags._[0] ?? ''); if (!fileId) { process.stderr.write('get needs a file id (see: schulcloud ls --long).\n'); return 2; } const api = new ApiClient(await loadCliConfig()); const response = await api.file(fileId); if (!response.body) { process.stderr.write('Empty response.\n'); return 1; } const fromHeader = /filename\*=UTF-8''([^;]+)/.exec(response.headers.get('content-disposition') ?? '')?.[1]; const name = flags.out ? String(flags.out) : fromHeader ? decodeURIComponent(fromHeader) : fileId; // basename() on the server-supplied name: it must not choose a directory. const target = flags.out ? resolve(String(flags.out)) : resolve(basename(name)); await mkdir(dirname(target), { recursive: true }); await pipeline(Readable.fromWeb(response.body as never), createWriteStream(target)); process.stdout.write(`${target}\n`); return 0; } async function fileManager(flags: Flags): Promise<number> { const [sub, ...args] = flags._ as string[]; const api = new ApiClient(await loadCliConfig()); const out = (line: string) => process.stdout.write(`${line}\n`); const long = Boolean(flags.long); switch (sub) { case 'ls': return fsList(api, args[0] ?? '/', long, out); case 'tree': return fsTree(api, args[0] ?? '/', Number(flags.depth ?? 3), Number(flags['max-folders'] ?? 200), out); case 'find': { if (!args[0]) { process.stderr.write('fs find needs a name, e.g.: schulcloud fs find Erbrecht --path /courses\n'); return 2; } const type = flags.type === 'folder' || flags.type === 'file' ? String(flags.type) : 'any'; return fsFind(api, args[0], String(flags.path ?? '/'), type, Number(flags['max-folders'] ?? 400), long, out); } case 'get': if (!args[0]) { process.stderr.write('fs get needs a path, e.g.: schulcloud fs get "/courses/<course>/<folder>"\n'); return 2; } return fsGet( api, args[0], { out: flags.out ? String(flags.out) : undefined, force: Boolean(flags.force), jobs: Number(flags.jobs ?? 3) }, out, ); default: process.stderr.write(`Unknown fs command "${sub ?? ''}". Use ls, tree, find or get.\n\n${USAGE}`); return 2; } } async function notes(flags: Flags): Promise<number> { const [sub, ...args] = flags._ as string[]; const out = (line: string) => process.stdout.write(`${line}\n`); // `--out` writes files directly, which is the one note command that needs no // server: a migration should be runnable and inspectable before anything is // sent anywhere. const offlineImport = sub === 'import' && Boolean(flags.out); const api = offlineImport ? undefined : new ApiClient(await loadCliConfig()); switch (sub) { case 'ls': case 'list': return noteList( api!, { ...(flags.subject ? { subject: String(flags.subject) } : {}), ...(flags.since ? { since: String(flags.since) } : {}), ...(flags.until ? { until: String(flags.until) } : {}), }, Boolean(flags.long), out, ); case 'show': case 'cat': if (!args[0]) { process.stderr.write('note show needs a path, e.g.: schulcloud note show "Deutsch/2026-09-15 Erörterung.md"\n'); return 2; } return noteShow(api!, args[0], out); case 'add': { const title = flags.title ? String(flags.title) : args[0]; if (!title) { process.stderr.write('note add needs --title.\n'); return 2; } // Piped text is the normal way in: it is how a note gets here from an // editor, a clipboard or another command. Typing it straight in works // too, but only if we say how it ends. if (!flags.text && process.stdin.isTTY) { process.stderr.write('Type the note, then Ctrl-D to save (Ctrl-C to abort):\n'); } const body = flags.text ? String(flags.text) : await readPiped(); if (!body?.trim()) { process.stderr.write('note add needs the note text: pass --text, or pipe it in.\n'); return 2; } return noteAdd( api!, { title, text: body, ...(flags.subject ? { subject: String(flags.subject) } : {}), ...(flags.date ? { date: String(flags.date) } : {}), ...(flags.tags ? { tags: String(flags.tags).split(',').map((tag) => tag.trim()).filter(Boolean) } : {}), append: Boolean(flags.append), }, out, ); } case 'import': if (!args[0]) { process.stderr.write('note import needs the export file, e.g.: schulcloud note import notes.ndjson\n'); return 2; } return noteImport( api, args[0], { ...(flags.out ? { outDir: resolve(String(flags.out)) } : {}), ...(flags.subject ? { subject: String(flags.subject) } : {}), dryRun: Boolean(flags['dry-run']), }, out, ); default: process.stderr.write(`Unknown note command "${sub ?? ''}". Use ls, show, add or import.\n\n${USAGE}`); return 2; } } async function runSync(flags: Flags): Promise<number> { const config = await loadCliConfig(); const root = flags.dir ? resolve(String(flags.dir)) : config.syncDir; const api = new ApiClient(config); const dryRun = Boolean(flags['dry-run']); process.stderr.write(`${dryRun ? 'Would sync' : 'Syncing'} to ${root}\n`); const summary = await sync(api, root, { dryRun, prune: Boolean(flags.prune), full: Boolean(flags.full), concurrency: Number(flags.jobs ?? 4), onEvent: (event) => process.stderr.write(describe(event, dryRun)), }); process.stderr.write( `\n${dryRun ? 'Would download' : 'Downloaded'} ${summary.downloaded} file(s) (${formatBytes(summary.bytes)})` + `, moved ${summary.moved}, unchanged ${summary.skipped}` + (summary.removed ? `, deleted ${summary.removed}` : '') + (summary.kept ? `, ${summary.kept} gone upstream but kept locally` : '') + (summary.failed ? `, FAILED ${summary.failed}` : '') + `\ncursor now ${summary.cursor}\n`, ); if (summary.kept > 0 && !flags.prune) { process.stderr.write('Files removed upstream were kept. Pass --prune to delete them locally.\n'); } return summary.failed > 0 ? 1 : 0; } async function refresh(flags: Flags): Promise<number> { const api = new ApiClient(await loadCliConfig()); const scope = flags.course ? String(flags.course) : undefined; process.stderr.write(`Asking the server to re-crawl ${scope ? `course ${scope}` : 'everything'}…\n`); let lastNote = 0; const result = (await api.refresh(scope, Boolean(flags.force), (seconds) => { // A note every half minute, so a long first crawl does not look hung. if (seconds - lastNote >= 30) { lastNote = seconds; process.stderr.write(` still crawling… ${seconds}s\n`); } })) as { crawlId: number; courses: number; files: number; mirrored: number; extracted: number; skipped: number; durationMs: number; joined?: boolean; }; process.stdout.write( `${result.joined ? 'Joined a crawl already running. ' : ''}` + `generation ${result.crawlId}: ${result.courses} course(s), ${result.files} files, ` + `${result.mirrored} newly mirrored, ${result.extracted} text-extracted, ${result.skipped} skipped ` + `(${(result.durationMs / 1000).toFixed(1)}s)\n`, ); return 0; } /** * The monthly chore: log in to Schulcloud in a private window, copy the `jwt` * cookie, paste it here, close the window. The server checks the token with * Schulcloud before swapping it in, so a bad paste changes nothing. */ async function token(flags: Flags): Promise<number> { const api = new ApiClient(await loadCliConfig()); const sub = flags._[0]; if (sub === undefined || sub === 'status') { process.stdout.write(`${describeToken(await api.token())}\n`); return 0; } if (sub !== 'set') { process.stderr.write(`Unknown token command "${sub}". Use "schulcloud token" or "schulcloud token set".\n`); return 2; } const pasted = process.stdin.isTTY ? await readHidden('Paste the value of the "jwt" cookie (input hidden): ') : await readPiped(); if (!pasted.trim()) { process.stderr.write('No token given.\n'); return 2; } process.stderr.write('Checking it with Schulcloud…\n'); const result = await api.replaceToken(pasted); process.stdout.write(`${result.changed ? 'Replaced' : 'Already in use'}: ${describeToken(result)}\n`); if (result.changed && !result.persisted) { process.stderr.write('Not saved on the server (STATE_DIR is unset): a restart falls back to TSC_JWT_COOKIE.\n'); } process.stdout.write('Now close the private window — left open, it logs this token out about two hours after login.\n'); return 0; } function describeToken(info: TokenInfo): string { const expiry = info.expiresAt ? `expires ${info.expiresAt.slice(0, 16).replace('T', ' ')} UTC (${info.daysLeft} day(s) left)` : 'expiry unknown'; const keepalive = info.keepalive; const session = !keepalive ? 'keepalive off' : keepalive.running ? `session alive${keepalive.budgetSeconds === undefined ? '' : `, ${Math.round(keepalive.budgetSeconds / 60)} min budget`}` : 'session ENDED — Schulcloud rejected the token; run: schulcloud token set'; const source = info.source === 'environment' ? 'from TSC_JWT_COOKIE' : info.source === 'state file' ? 'saved from an earlier replacement' : info.source; const warning = info.daysLeft !== undefined && info.daysLeft <= 7 ? '\nRenew it soon: schulcloud token set' : ''; return `${expiry}; ${session}; ${source}${warning}`; } function describe(event: SyncEvent, dryRun: boolean): string { switch (event.type) { case 'download': return ` ${dryRun ? 'would get' : 'get '} ${event.entry.path}${event.reason === 'new' ? '' : ` (${event.reason})`}\n`; case 'move': return ` ${dryRun ? 'would move' : 'move '} ${event.from} → ${event.entry.path}\n`; case 'remove': return ` ${event.kept ? 'gone upstream, kept' : dryRun ? 'would delete' : 'delete '} ${event.path}\n`; case 'error': return ` FAILED ${event.entry.path}: ${event.message}\n`; case 'skip': return ''; } } // --- flags --------------------------------------------------------------- interface Flags { _: string[]; [key: string]: string | boolean | string[] | undefined; } /** Minimal flag parsing: --key value, --key=value, --flag, and positionals. */ function parseFlags(argv: string[]): Flags { const flags: Flags = { _: [] }; for (let i = 0; i < argv.length; i++) { const token = argv[i]!; if (!token.startsWith('--')) { (flags._ as string[]).push(token); continue; } const body = token.slice(2); const eq = body.indexOf('='); if (eq !== -1) { flags[body.slice(0, eq)] = body.slice(eq + 1); continue; } const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[body] = next; i++; } else { flags[body] = true; } } return flags; } main(process.argv.slice(2)) .then((code) => process.exit(code)) .catch((error: unknown) => { if (error instanceof ApiError) { process.stderr.write(`${error.message}\n`); } else { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); } process.exit(1); });