From f3b2fc01082e754f7dfe73caa4f0a4f207a2adfb Mon Sep 17 00:00:00 2001 From: YurenHao0426 Date: Sun, 13 Sep 2026 04:05:38 +0000 Subject: Add headless CLI for Overleaf project management (lattex-cli) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/cli/ module providing a headless CLI for AI agents to work with Overleaf projects from a server with no display. Commands: auth, projects, clone, pull, status, push, compile. Key design decisions: - Uses Node.js https module (no Electron dependency), same approach as the existing MCP server in src/mcp/lattex.mjs - Reuses overleafProtocol.ts for Socket.IO v0.9 parsing (pure, no Electron) - Separate esbuild bundle (out/cli/lattex-cli.mjs) — does not touch the Electron app build - 64 tests for arg parsing, file tree walking, diff logic, and log parsing Files: - src/cli/main.ts — CLI entry point with all commands - src/cli/args.ts — argument parser - src/cli/overleafApi.ts — Overleaf API client using https + ws - src/cli/fileTree.ts — project root folder walker - src/cli/localState.ts — per-clone state (.lattex-cli.json) and auth storage - src/cli/diff.ts — local vs remote diff logic - src/cli/logParser.ts — LaTeX compile log parser - src/cli/test.ts — unit tests - tsconfig.cli.json — TypeScript config for CLI Co-Authored-By: Claude Opus 5 --- src/cli/logParser.ts | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/cli/logParser.ts (limited to 'src/cli/logParser.ts') diff --git a/src/cli/logParser.ts b/src/cli/logParser.ts new file mode 100644 index 0000000..d59d7df --- /dev/null +++ b/src/cli/logParser.ts @@ -0,0 +1,99 @@ +// LaTeX compile log parser (extracted from src/mcp/lattex.mjs) + +export interface LogEntry { + level: 'error' | 'warning' + message: string + file?: string + line?: number +} + +export function parseCompileLog(raw: string): LogEntry[] { + const entries: LogEntry[] = [] + const lines = raw.split('\n') + + for (let i = 0; i < lines.length; i++) { + const ln = lines[i] + + if (/^!/.test(ln) || /LaTeX Error:/.test(ln)) { + let msg = ln.replace(/^!\s*/, '') + while (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].startsWith('l.') && !lines[i + 1].startsWith('!')) { + i++ + if (lines[i].trim()) msg += ' ' + lines[i].trim() + } + let lineNum: number | undefined + if (i + 1 < lines.length && /^l\.(\d+)/.test(lines[i + 1])) { + i++ + lineNum = parseInt(lines[i].match(/^l\.(\d+)/)![1]) + } + entries.push({ level: 'error', message: msg.trim(), line: lineNum }) + continue + } + + const fileLineErr = ln.match(/^\.\/(.+?):(\d+):\s*(.+)/) + if (fileLineErr) { + const msg = fileLineErr[3] + const isWarning = /warning/i.test(msg) + entries.push({ + level: isWarning ? 'warning' : 'error', + message: msg, + file: fileLineErr[1], + line: parseInt(fileLineErr[2]) + }) + continue + } + + const pkgWarn = ln.match(/Package (\S+) Warning:\s*(.*)/) + if (pkgWarn) { + let msg = `[${pkgWarn[1]}] ${pkgWarn[2]}` + let warnLine: number | undefined + while (i + 1 < lines.length && /^\(/.test(lines[i + 1])) { + i++ + msg += ' ' + lines[i].replace(/^\([^)]*\)\s*/, '').trim() + const lineMatch = lines[i].match(/on input line (\d+)/) + if (lineMatch) warnLine = parseInt(lineMatch[1]) + } + if (!warnLine) { + const lineMatch = msg.match(/on input line (\d+)/) + if (lineMatch) warnLine = parseInt(lineMatch[1]) + } + entries.push({ level: 'warning', message: msg.trim(), line: warnLine }) + continue + } + + const latexWarn = ln.match(/LaTeX Warning:\s*(.*)/) + if (latexWarn) { + let msg = latexWarn[1] + while (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].match(/^[(!.]/) && lines[i + 1].startsWith(' ')) { + i++ + msg += ' ' + lines[i].trim() + } + const lineMatch = msg.match(/on input line (\d+)/) + entries.push({ level: 'warning', message: msg.trim(), line: lineMatch ? parseInt(lineMatch[1]) : undefined }) + continue + } + + if (/^(Overfull|Underfull)/.test(ln)) { + const paraMatch = ln.match(/at lines (\d+)--(\d+)/) || ln.match(/in paragraph at lines (\d+)--(\d+)/) + entries.push({ level: 'warning', message: ln.trim(), line: paraMatch ? parseInt(paraMatch[1]) : undefined }) + continue + } + + if (/File .* not found/.test(ln)) { + entries.push({ level: 'error', message: ln.trim() }) + } + } + + // Deduplicate + const seen = new Set() + return entries.filter((e) => { + const key = `${e.level}:${e.message}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +export function formatEntry(e: LogEntry): string { + const loc = [e.file, e.line].filter(Boolean).join(':') + return `[${e.level.toUpperCase()}]${loc ? ` ${loc}:` : ''} ${e.message}` +} -- cgit v1.2.3