overleaf-forge 2.9.1 → 2.12.0
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 +91 -42
- package/dependency-index.js +189 -0
- package/efficiency.js +138 -0
- package/overleaf-mcp-server.js +457 -243
- package/package.json +7 -2
- package/render-cache.js +161 -0
- package/runtime-observability.js +109 -0
- package/transactions.js +219 -0
- package/writing-guidelines.md +24 -222
package/overleaf-mcp-server.js
CHANGED
|
@@ -13,6 +13,15 @@ import { execFile as execFileCallback } from 'child_process';
|
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
15
|
import os from 'os';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { dependencyIndex, changeReport } from './dependency-index.js';
|
|
18
|
+
import { renderPages } from './render-cache.js';
|
|
19
|
+
import { applyChanges, publishChanges } from './transactions.js';
|
|
20
|
+
import { observeTool, usageStats, toolError } from './runtime-observability.js';
|
|
21
|
+
import { versionedContext, buildFingerprint, sectionBundle, sectionText, controlledBuildOptions } from './efficiency.js';
|
|
22
|
+
const verifiedBuilds = new Map();
|
|
23
|
+
const recentPasses = new Map();
|
|
24
|
+
const buildQueues = new Map();
|
|
16
25
|
|
|
17
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
27
|
const __dirname = path.dirname(__filename);
|
|
@@ -85,10 +94,20 @@ const CONFIG_PATH = path.join(DATA_HOME, 'projects.json');
|
|
|
85
94
|
const CONTEXTS_DIR = path.join(DATA_HOME, 'contexts');
|
|
86
95
|
const DEFAULT_REPO_DIR = path.join(DATA_HOME, 'repos');
|
|
87
96
|
const BUNDLED_TEMPLATES_DIR = path.join(PACKAGE_DIR, 'templates');
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
// Guidelines resolve personal-first:
|
|
98
|
+
// 1. <dataHome>/writing-guidelines.local.md (gitignored; never ships)
|
|
99
|
+
// 2. <dataHome>/writing-guidelines.md (user copy, when dataHome is not the package)
|
|
100
|
+
// 3. <packageDir>/writing-guidelines.md (bundled generic default)
|
|
101
|
+
// The .local name is what keeps a personal copy distinct when dataHome IS the
|
|
102
|
+
// package dir (a local clone with projects.json), where 2 and 3 are one file.
|
|
103
|
+
// Resolved per call so creating or deleting the local file needs no restart.
|
|
104
|
+
export function resolveGuidelinesPath({ dataHome, packageDir, exists }) {
|
|
105
|
+
const local = path.join(dataHome, 'writing-guidelines.local.md');
|
|
106
|
+
if (exists(local)) return local;
|
|
107
|
+
const user = path.join(dataHome, 'writing-guidelines.md');
|
|
108
|
+
if (exists(user)) return user;
|
|
109
|
+
return path.join(packageDir, 'writing-guidelines.md');
|
|
110
|
+
}
|
|
92
111
|
|
|
93
112
|
// Where scaffold templates (main.tex skeleton, context-scaffold.md) are read.
|
|
94
113
|
// Precedence: settings.templatesDir → $OVERLEAF_MCP_TEMPLATES → ~/.overleaf-mcp/
|
|
@@ -197,7 +216,8 @@ const SETTING_HELP = {
|
|
|
197
216
|
ssaSubdir: 'What subfolder name should new SSAs go under inside each course folder? (e.g. "MY SSAs")',
|
|
198
217
|
templatesDir: 'Use your own scaffold templates? Give the directory holding main.tex / context-scaffold.md (blank keeps the bundled examples).',
|
|
199
218
|
voiceLinter: 'Use your own prose linter for voice_lint? Give the command (takes a file path, exits non-zero on findings; blank keeps the bundled example).',
|
|
200
|
-
|
|
219
|
+
autoPush: 'Should edit tools push to Overleaf immediately (true), or commit locally until publish_changes sends a verified batch (false, the default)?',
|
|
220
|
+
gitToken: 'Overleaf git token (prefer the OVERLEAF_GIT_TOKEN env var; set here only if you must store it in projects.json).',
|
|
201
221
|
};
|
|
202
222
|
const SETTING_KEYS = Object.keys(SETTING_HELP);
|
|
203
223
|
const SETTING_PATHY = new Set(['repoDir', 'academicRoot', 'templatesDir']);
|
|
@@ -218,6 +238,14 @@ export function mergeSettings(current, args, homeDir) {
|
|
|
218
238
|
return { settings, provided };
|
|
219
239
|
}
|
|
220
240
|
|
|
241
|
+
// Whether a mutating tool pushes to Overleaf. An explicit per-call `push` wins,
|
|
242
|
+
// then settings.autoPush; the default is false, so edits stay local commits
|
|
243
|
+
// until publish_changes verifies and sends them in one deliberate step.
|
|
244
|
+
export function resolvePush(settings, args) {
|
|
245
|
+
if (typeof args?.push === 'boolean') return args.push;
|
|
246
|
+
return settings?.autoPush === true;
|
|
247
|
+
}
|
|
248
|
+
|
|
221
249
|
// Default project resolution:
|
|
222
250
|
// 1. explicit projectName argument
|
|
223
251
|
// 2. project whose `cwd` is a prefix of SESSION_CWD (longest match wins)
|
|
@@ -321,23 +349,71 @@ class OverleafGitClient {
|
|
|
321
349
|
}
|
|
322
350
|
// Repair older clones that embedded the token in the remote URL.
|
|
323
351
|
await this._git(['-C', this.repoPath, 'remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
|
|
352
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'pull', '--ff-only'], { auth: true });
|
|
353
|
+
return stdout;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async requireLocal() {
|
|
357
|
+
if (!(await this._hasRepo())) throw Object.assign(new Error('Local clone missing; call sync_project explicitly.'), { code: 'LOCAL_CLONE_MISSING' });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Mutations that push absorb remote edits first; local-only mutations stay
|
|
361
|
+
// off the network entirely, like reads.
|
|
362
|
+
async _prepareMutation(push) {
|
|
363
|
+
if (push) await this.cloneOrPull();
|
|
364
|
+
else await this.requireLocal();
|
|
365
|
+
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
366
|
+
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
367
|
+
return this._head();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async _head() {
|
|
371
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', 'HEAD']);
|
|
372
|
+
return stdout.trim();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// HEAD plus the number of local commits not yet on the remote-tracking branch.
|
|
376
|
+
// unpublished is null when there is no tracking ref to compare against.
|
|
377
|
+
async pendingState() {
|
|
378
|
+
const head = await this._head();
|
|
379
|
+
const branch = await this._currentBranch();
|
|
380
|
+
let unpublished = null;
|
|
324
381
|
try {
|
|
325
|
-
const { stdout } = await this._git(['-C', this.repoPath, '
|
|
326
|
-
|
|
327
|
-
} catch {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
382
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'rev-list', '--count', `origin/${branch}..HEAD`]);
|
|
383
|
+
unpublished = Number(stdout.trim());
|
|
384
|
+
} catch { /* no tracking ref */ }
|
|
385
|
+
return { head, unpublished };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Finish a mutation whose commit is already made. push:false keeps it local
|
|
389
|
+
// for publish_changes. A refused push rolls back to preHead, this operation's
|
|
390
|
+
// own starting point: only its commit is undone, and earlier unpublished
|
|
391
|
+
// commits survive. (Resetting to origin/<branch> would silently drop them.)
|
|
392
|
+
async _finishMutation(preHead, push, { merge = false, refusal }) {
|
|
393
|
+
if (!push) return { pushed: false, committed: true, ...(await this.pendingState()) };
|
|
394
|
+
if (merge) return this._pushWithMerge(preHead);
|
|
395
|
+
try {
|
|
396
|
+
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
397
|
+
} catch (e) {
|
|
398
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]).catch(() => {});
|
|
399
|
+
throw new Error(`${refusal} (${(e.stderr || e.message || '').slice(0, 120)})`);
|
|
400
|
+
}
|
|
401
|
+
return { pushed: true };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// git commit that reports "nothing to commit" as a value instead of throwing.
|
|
405
|
+
async _commit(message) {
|
|
406
|
+
try {
|
|
407
|
+
await this._git(['-C', this.repoPath, 'commit', '-m', message]);
|
|
408
|
+
return true;
|
|
409
|
+
} catch (e) {
|
|
410
|
+
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) return false;
|
|
411
|
+
throw e;
|
|
336
412
|
}
|
|
337
413
|
}
|
|
338
414
|
|
|
339
415
|
async listFiles(extension = '.tex') {
|
|
340
|
-
await this.
|
|
416
|
+
await this.requireLocal();
|
|
341
417
|
const out = [];
|
|
342
418
|
const walk = async (dir) => {
|
|
343
419
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -355,14 +431,16 @@ class OverleafGitClient {
|
|
|
355
431
|
}
|
|
356
432
|
|
|
357
433
|
async readFile(filePath) {
|
|
358
|
-
await this.
|
|
359
|
-
const
|
|
434
|
+
await this.requireLocal();
|
|
435
|
+
const root = realpathSync(this.repoPath);
|
|
436
|
+
const fullPath = realpathSync(path.resolve(root, filePath));
|
|
437
|
+
if (!fullPath.startsWith(root + path.sep)) throw Object.assign(new Error('File must be inside the project.'), { code: 'INVALID_INPUT' });
|
|
360
438
|
return await readFile(fullPath, 'utf-8');
|
|
361
439
|
}
|
|
362
440
|
|
|
363
441
|
// git blob SHA of a file at the current tip; null if the file isn't tracked.
|
|
364
|
-
async getBlobSha(filePath
|
|
365
|
-
|
|
442
|
+
async getBlobSha(filePath) {
|
|
443
|
+
await this.requireLocal();
|
|
366
444
|
try {
|
|
367
445
|
const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', `HEAD:${filePath}`]);
|
|
368
446
|
return stdout.trim();
|
|
@@ -387,63 +465,179 @@ class OverleafGitClient {
|
|
|
387
465
|
// Run latexmk from the repo root (so the project's .latexmkrc -- shell-escape,
|
|
388
466
|
// the python@3.13 PATH fix for minted, $pdf_mode -- applies, and refs/citations/
|
|
389
467
|
// reruns resolve). clean:true adds -gg to force a complete from-scratch rebuild.
|
|
390
|
-
async _runLatexmk(filePath, engine = 'lualatex', { clean = false } = {}) {
|
|
391
|
-
|
|
468
|
+
async _runLatexmk(filePath, engine = 'lualatex', { clean = false, controlled = false } = {}) {
|
|
469
|
+
const full = path.resolve(this.repoPath, filePath);
|
|
470
|
+
if (!full.startsWith(path.resolve(this.repoPath) + path.sep) || !filePath.endsWith('.tex') || filePath.startsWith('-')) throw new Error('Build entrypoint must be a .tex path inside the project.');
|
|
471
|
+
if (!(await this._hasRepo())) throw new Error('Local clone missing; call sync_project explicitly before building.');
|
|
392
472
|
const engineFlag = { pdflatex: '-pdf', xelatex: '-xelatex', lualatex: '-lualatex' }[engine];
|
|
393
473
|
if (!engineFlag) {
|
|
394
474
|
throw new Error(`Invalid engine "${engine}". Choose from: pdflatex, xelatex, lualatex`);
|
|
395
475
|
}
|
|
396
476
|
const texbin = '/Library/TeX/texbin';
|
|
397
477
|
const env = { ...process.env, PATH: `${texbin}:${process.env.PATH || ''}` };
|
|
398
|
-
const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error'];
|
|
478
|
+
const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error', '-recorder'];
|
|
479
|
+
// -norc must precede all other options so no user or project Perl config runs.
|
|
480
|
+
if (controlled) args.unshift('-norc', '-no-shell-escape');
|
|
399
481
|
if (clean) args.push('-gg');
|
|
400
482
|
args.push(filePath);
|
|
483
|
+
let commandFailed = false;
|
|
401
484
|
const { stdout, stderr } = await execFile(
|
|
402
485
|
path.join(texbin, 'latexmk'), args,
|
|
403
486
|
{ cwd: this.repoPath, timeout: 180000, maxBuffer: 20 * 1024 * 1024, env }
|
|
404
|
-
).catch(e =>
|
|
487
|
+
).catch(e => { commandFailed = true; return { stdout: e.stdout || '', stderr: e.stderr || e.message }; });
|
|
405
488
|
const pdfPath = path.join(this.repoPath, filePath.replace(/\.tex$/, '.pdf'));
|
|
406
489
|
let pdfExists = false;
|
|
407
490
|
try { await access(pdfPath); pdfExists = true; } catch { /* no pdf */ }
|
|
408
|
-
return { stdout, stderr, log: `${stdout}\n${stderr}`, pdfPath: pdfExists ? pdfPath : null };
|
|
491
|
+
return { stdout, stderr, commandFailed, log: `${stdout}\n${stderr}`, pdfPath: !commandFailed && pdfExists ? pdfPath : null };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async compileFile(filePath, engine = 'lualatex', options = {}) {
|
|
495
|
+
return this.verifyBuild(filePath, engine, { ...options, force: true, clean: false });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Explicit sync. Fetches, then acts on the ahead/behind relation:
|
|
499
|
+
// behind only -> fast-forward
|
|
500
|
+
// ahead only / equal -> nothing to do (ahead = unpublished local commits)
|
|
501
|
+
// diverged -> report both sides and change nothing, unless a
|
|
502
|
+
// strategy is chosen:
|
|
503
|
+
// 'rebase' replays local commits onto the remote; a conflict aborts back
|
|
504
|
+
// to the untouched pre-sync state.
|
|
505
|
+
// 'reset' discards local work to match the remote. Requires confirm to
|
|
506
|
+
// equal the reported local HEAD (proof the report was read), and
|
|
507
|
+
// tags the old HEAD and any uncommitted edits as mcp-backup/*
|
|
508
|
+
// first, so nothing becomes unrecoverable.
|
|
509
|
+
async syncProject({ strategy, confirm } = {}) {
|
|
510
|
+
if (!(await this._hasRepo())) {
|
|
511
|
+
await this.cloneOrPull();
|
|
512
|
+
return { state: 'cloned', ...(await this.pendingState()) };
|
|
513
|
+
}
|
|
514
|
+
if (strategy !== undefined && strategy !== 'rebase' && strategy !== 'reset') {
|
|
515
|
+
throw new Error(`Unknown strategy "${strategy}". Use "rebase" or "reset".`);
|
|
516
|
+
}
|
|
517
|
+
const g = (args, opts) => this._git(['-C', this.repoPath, ...args], opts);
|
|
518
|
+
await g(['remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
|
|
519
|
+
await g(['fetch', 'origin'], { auth: true });
|
|
520
|
+
// rebase and stash create write commits, which need a committer identity.
|
|
521
|
+
await g(['config', 'user.email', 'claude@anthropic.com']);
|
|
522
|
+
await g(['config', 'user.name', 'Claude']);
|
|
523
|
+
const branch = await this._currentBranch();
|
|
524
|
+
const upstream = `origin/${branch}`;
|
|
525
|
+
const count = async range => Number((await g(['rev-list', '--count', range])).stdout.trim());
|
|
526
|
+
const ahead = await count(`${upstream}..HEAD`);
|
|
527
|
+
const behind = await count(`HEAD..${upstream}`);
|
|
528
|
+
const head = await this._head();
|
|
529
|
+
const dirty = (await g(['status', '--porcelain=v1', '--untracked-files=no'])).stdout.split('\n').filter(Boolean).map(l => l.slice(3));
|
|
530
|
+
|
|
531
|
+
if (strategy === 'reset') {
|
|
532
|
+
if (confirm !== head) {
|
|
533
|
+
throw new Error(`reset discards local work; pass confirm: "${head}" (the current local HEAD) after reviewing the sync_project report.`);
|
|
534
|
+
}
|
|
535
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
536
|
+
const backups = [`mcp-backup/${stamp}`];
|
|
537
|
+
await g(['tag', backups[0], head]);
|
|
538
|
+
if (dirty.length) {
|
|
539
|
+
// stash create snapshots uncommitted tracked edits as a commit without
|
|
540
|
+
// touching the working tree; the tag keeps it reachable.
|
|
541
|
+
const wip = (await g(['stash', 'create', `mcp-backup ${stamp}`])).stdout.trim();
|
|
542
|
+
if (wip) { backups.push(`mcp-backup/${stamp}-wip`); await g(['tag', backups[1], wip]); }
|
|
543
|
+
}
|
|
544
|
+
await g(['reset', '--hard', upstream]);
|
|
545
|
+
return { state: 'reset', discardedCommits: ahead, discardedFiles: dirty, backups, ...(await this.pendingState()) };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (behind === 0) return { state: ahead ? 'ahead' : 'up-to-date', ahead, behind, head, dirtyFiles: dirty };
|
|
549
|
+
if (ahead === 0) {
|
|
550
|
+
await g(['merge', '--ff-only', upstream]);
|
|
551
|
+
return { state: 'fast-forwarded', ahead, behind, ...(await this.pendingState()) };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const log = async range => (await g(['log', '--format=%h %s', '--name-only', range])).stdout.trim();
|
|
555
|
+
const report = {
|
|
556
|
+
state: 'diverged', ahead, behind, head, upstream: (await g(['rev-parse', upstream])).stdout.trim(),
|
|
557
|
+
localCommits: await log(`${upstream}..HEAD`), remoteCommits: await log(`HEAD..${upstream}`), dirtyFiles: dirty,
|
|
558
|
+
options: 'strategy:"rebase" replays local commits onto Overleaf (aborts cleanly on conflict); strategy:"reset" with confirm:<head> discards local work after tagging a backup.',
|
|
559
|
+
};
|
|
560
|
+
if (strategy !== 'rebase') return report;
|
|
561
|
+
if (dirty.length) throw new Error(`rebase needs a clean working tree; uncommitted edits in: ${dirty.join(', ')}. Commit them or choose reset.`);
|
|
562
|
+
try {
|
|
563
|
+
await g(['rebase', upstream]);
|
|
564
|
+
} catch {
|
|
565
|
+
const conflicts = (await g(['diff', '--name-only', '--diff-filter=U']).catch(() => ({ stdout: '' }))).stdout.trim().split('\n').filter(Boolean);
|
|
566
|
+
await g(['rebase', '--abort']).catch(() => {});
|
|
567
|
+
return { ...report, state: 'rebase-conflict', conflicts, note: 'Rebase aborted; local state is unchanged.' };
|
|
568
|
+
}
|
|
569
|
+
return { state: 'rebased', ...(await this.pendingState()) };
|
|
409
570
|
}
|
|
410
571
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
572
|
+
// options.lint (true = every .tex file, or an array of paths) adds the voice
|
|
573
|
+
// linter to the gate: findings fail it like an undefined reference does.
|
|
574
|
+
// Lint runs outside the build cache, so a reused build verdict is never
|
|
575
|
+
// mutated and lint always sees the current files.
|
|
576
|
+
async verifyBuild(filePath, engine = 'lualatex', options = {}) {
|
|
577
|
+
const key = path.resolve(this.repoPath);
|
|
578
|
+
const previous = buildQueues.get(key) || Promise.resolve();
|
|
579
|
+
const task = previous.catch(() => {}).then(() => this._verifyLocal(filePath, engine, options));
|
|
580
|
+
buildQueues.set(key, task);
|
|
581
|
+
let verdict;
|
|
582
|
+
try { verdict = await task; } finally { if (buildQueues.get(key) === task) buildQueues.delete(key); }
|
|
583
|
+
if (!options.lint) return verdict;
|
|
584
|
+
const files = Array.isArray(options.lint) ? options.lint : await this.listFiles('.tex');
|
|
585
|
+
const lint = await this.lintFiles(files, options.lintCommand);
|
|
586
|
+
return { ...verdict, lint, pass: verdict.pass && lint.clean };
|
|
417
587
|
}
|
|
418
588
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
589
|
+
async lintFiles(files, command) {
|
|
590
|
+
const results = [];
|
|
591
|
+
for (const file of files) results.push({ file, ...(await this.voiceLint(file, { command })) });
|
|
592
|
+
return { clean: results.every(r => r.clean), results };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async _verifyLocal(filePath, engine, options = {}) {
|
|
596
|
+
const { force = false, clean = true } = options;
|
|
597
|
+
const config = controlledBuildOptions(options);
|
|
598
|
+
// Resolved so the gate (client.repoPath) and publish (path.resolve(root)) share a key.
|
|
599
|
+
const key = JSON.stringify([path.resolve(this.repoPath),filePath,engine,config]);
|
|
600
|
+
const fingerprint = () => buildFingerprint(this.repoPath,filePath,engine,config).catch(()=>null);
|
|
601
|
+
const sources = () => buildFingerprint(this.repoPath,filePath,engine,{...config,sourcesOnly:true});
|
|
602
|
+
const cached = verifiedBuilds.get(key);
|
|
603
|
+
const before = await sources();
|
|
604
|
+
if (!force && cached && cached.fingerprint === await fingerprint()) return { ...cached.verdict, reused: true };
|
|
605
|
+
// publish_changes re-verifies a commit the final gate usually just passed.
|
|
606
|
+
// Projects with executable config (rc files, minted, shell escape) never
|
|
607
|
+
// qualify for the full cache above, so without this every publish would
|
|
608
|
+
// rebuild from scratch. Reuse is allowed only when every project file is
|
|
609
|
+
// byte-identical to the state right after that PASS (the sources hash
|
|
610
|
+
// covers tracked and untracked files, the environment and the day) within
|
|
611
|
+
// this process. Not covered: a TeX installation change in between.
|
|
612
|
+
const recent = recentPasses.get(key);
|
|
613
|
+
if (options.reuseRecentPass && !force && recent && recent.sources === before) return { ...recent.verdict, reused: true };
|
|
614
|
+
verifiedBuilds.delete(key);
|
|
615
|
+
const { log: runLog, pdfPath, commandFailed } = await this._runLatexmk(filePath,engine,{clean,controlled:config.controlled});
|
|
616
|
+
const logPath = path.join(this.repoPath,filePath.replace(/\.tex$/,'.log'));
|
|
429
617
|
let finalLog = runLog;
|
|
430
|
-
try { finalLog = await readFile(
|
|
618
|
+
try { finalLog = await readFile(logPath,'utf8'); } catch { /* failed before log creation */ }
|
|
431
619
|
const verdict = classifyBuildLog(finalLog);
|
|
432
|
-
|
|
433
|
-
// can't yield a false PASS; then recompute the verdict.
|
|
620
|
+
if (commandFailed) verdict.errors.push('latexmk command failed; any previous PDF is not a successful build.');
|
|
434
621
|
verdict.pdfProduced = pdfPath !== null;
|
|
435
|
-
verdict.pass = verdict.pdfProduced
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
verdict.
|
|
622
|
+
verdict.pass = verdict.pdfProduced && !verdict.errors.length && !verdict.undefinedRefs.length && !verdict.undefinedCitations.length;
|
|
623
|
+
verdict.logPath = logPath;
|
|
624
|
+
verdict.pdfPath = pdfPath;
|
|
625
|
+
verdict.tail = (commandFailed ? runLog : finalLog).slice(-2500);
|
|
626
|
+
verdict.reused = false;
|
|
627
|
+
const after = await sources();
|
|
628
|
+
if (before && before !== after) { verdict.pass = false; verdict.errors.push('Project inputs changed during the build; verify the latest source again.'); }
|
|
629
|
+
const print = before && before === after ? await fingerprint() : null;
|
|
630
|
+
verdict.cacheEligible = Boolean(print);
|
|
631
|
+
if (verdict.pass && print) verifiedBuilds.set(key,{ fingerprint:print,verdict });
|
|
632
|
+
if (verdict.pass && before === after) recentPasses.set(key, { sources: after, verdict });
|
|
633
|
+
else recentPasses.delete(key);
|
|
440
634
|
return verdict;
|
|
441
635
|
}
|
|
442
636
|
|
|
443
637
|
// Read-only grep across tracked files. Regex by default; fixed -> -F; ignoreCase -> -i.
|
|
444
638
|
async searchText({ query, fixed = false, ignoreCase = false, extension } = {}) {
|
|
445
639
|
if (!query) throw new Error('search_text needs a query.');
|
|
446
|
-
await this.
|
|
640
|
+
await this.requireLocal();
|
|
447
641
|
const args = ['-C', this.repoPath, 'grep', '-n', '--no-color', fixed ? '-F' : '-E'];
|
|
448
642
|
if (ignoreCase) args.push('-i');
|
|
449
643
|
args.push('-e', query);
|
|
@@ -458,34 +652,28 @@ class OverleafGitClient {
|
|
|
458
652
|
}
|
|
459
653
|
}
|
|
460
654
|
|
|
461
|
-
// Append a BibTeX entry to refs.bib (reject a duplicate key), commit,
|
|
462
|
-
|
|
655
|
+
// Append a BibTeX entry to refs.bib (reject a duplicate key), commit, and
|
|
656
|
+
// push when push is true.
|
|
657
|
+
async addCitation({ entry, commitMessage, push = true } = {}) {
|
|
463
658
|
if (!entry || !entry.trim()) throw new Error('add_citation needs a BibTeX entry.');
|
|
464
659
|
const m = entry.match(/@\w+\s*\{\s*([^,\s]+)/);
|
|
465
660
|
if (!m) throw new Error('Could not find a BibTeX key in the entry (expected @type{key, ...}).');
|
|
466
661
|
const key = m[1];
|
|
467
|
-
await this.
|
|
662
|
+
const preHead = await this._prepareMutation(push);
|
|
468
663
|
const bibPath = path.join(this.repoPath, 'refs.bib');
|
|
469
664
|
let current = '';
|
|
470
665
|
try { current = await readFile(bibPath, 'utf-8'); } catch { /* missing -> create */ }
|
|
471
666
|
const dup = new RegExp(`@\\w+\\s*\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*,`);
|
|
472
667
|
if (dup.test(current)) throw new Error(`citation key "${key}" already in refs.bib.`);
|
|
473
668
|
await writeFile(bibPath, current.replace(/\s*$/, '') + '\n\n' + entry.trim() + '\n', 'utf-8');
|
|
474
|
-
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
475
|
-
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
476
669
|
await this._git(['-C', this.repoPath, 'add', '--', 'refs.bib']);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
} catch (e) {
|
|
480
|
-
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) return { pushed: false, reason: 'nothing to commit', key };
|
|
481
|
-
throw e;
|
|
482
|
-
}
|
|
483
|
-
return { ...(await this._pushWithMerge()), key };
|
|
670
|
+
if (!(await this._commit(commitMessage || `Add citation ${key}`))) return { pushed: false, reason: 'nothing to commit', key };
|
|
671
|
+
return { ...(await this._finishMutation(preHead, push, { merge: true })), key };
|
|
484
672
|
}
|
|
485
673
|
|
|
486
674
|
// Read-only: cited keys (across .tex) vs defined keys (refs.bib).
|
|
487
675
|
async citeLint() {
|
|
488
|
-
await this.
|
|
676
|
+
await this.requireLocal();
|
|
489
677
|
const texFiles = await this.listFiles('.tex');
|
|
490
678
|
const citeRe = /\\(?:cite|autocite|parencite|citep|citet|textcite|footcite|nocite)\*?(?:\[[^\]]*\])*\{([^}]+)\}/g;
|
|
491
679
|
const cited = new Set();
|
|
@@ -519,7 +707,7 @@ class OverleafGitClient {
|
|
|
519
707
|
|
|
520
708
|
// Local rollback point: a lightweight tag mcp-snap/<label> at HEAD (not pushed).
|
|
521
709
|
async checkpoint(label) {
|
|
522
|
-
await this.
|
|
710
|
+
await this.requireLocal();
|
|
523
711
|
const name = `mcp-snap/${(label && label.trim()) || `snap-${Date.now()}`}`;
|
|
524
712
|
let exists = false;
|
|
525
713
|
try { await this._git(['-C', this.repoPath, 'rev-parse', '--verify', '--quiet', `refs/tags/${name}`]); exists = true; } catch { exists = false; }
|
|
@@ -529,10 +717,12 @@ class OverleafGitClient {
|
|
|
529
717
|
return { label: name, head: stdout.trim() };
|
|
530
718
|
}
|
|
531
719
|
|
|
532
|
-
// Forward-restore the snapshot's tree as a new commit on top of HEAD,
|
|
533
|
-
// No history rewrite, no force-push (the new commit
|
|
534
|
-
|
|
535
|
-
|
|
720
|
+
// Forward-restore the snapshot's tree as a new commit on top of HEAD, pushed
|
|
721
|
+
// when push is true. No history rewrite, no force-push (the new commit
|
|
722
|
+
// descends from HEAD). The fast-forward refuses rather than overwriting
|
|
723
|
+
// uncommitted local edits to files the restore would change.
|
|
724
|
+
async restore(label, { push = true } = {}) {
|
|
725
|
+
const preHead = await this._prepareMutation(push);
|
|
536
726
|
const name = String(label || '').startsWith('mcp-snap/') ? label : `mcp-snap/${label}`;
|
|
537
727
|
let tree;
|
|
538
728
|
try { ({ stdout: tree } = await this._git(['-C', this.repoPath, 'rev-parse', `${name}^{tree}`])); }
|
|
@@ -541,19 +731,10 @@ class OverleafGitClient {
|
|
|
541
731
|
throw new Error(`snapshot "${name}" not found. Available: ${tags || '(none)'}`);
|
|
542
732
|
}
|
|
543
733
|
tree = tree.trim();
|
|
544
|
-
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
545
|
-
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
546
734
|
const { stdout: commit } = await this._git(['-C', this.repoPath, 'commit-tree', tree, '-p', 'HEAD', '-m', `restore: ${name}`]);
|
|
547
|
-
await this._git(['-C', this.repoPath, '
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
} catch (e) {
|
|
551
|
-
const branch = await this._currentBranch();
|
|
552
|
-
await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
|
|
553
|
-
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
554
|
-
throw new Error(`restore: Overleaf moved during the rollback; refused. Re-run after re-pulling. (${(e.stderr || e.message || '').slice(0, 120)})`);
|
|
555
|
-
}
|
|
556
|
-
return { pushed: true, label: name, restoredTo: tree };
|
|
735
|
+
await this._git(['-C', this.repoPath, 'merge', '--ff-only', commit.trim()]);
|
|
736
|
+
const res = await this._finishMutation(preHead, push, { refusal: 'restore: Overleaf moved during the rollback; refused. Run sync_project, then retry.' });
|
|
737
|
+
return { ...res, label: name, restoredTo: tree };
|
|
557
738
|
}
|
|
558
739
|
|
|
559
740
|
// Run the configured voice linter on a file; advisory, read-only.
|
|
@@ -605,9 +786,11 @@ class OverleafGitClient {
|
|
|
605
786
|
}
|
|
606
787
|
|
|
607
788
|
// Push origin HEAD. If the remote moved during the op (non-fast-forward),
|
|
608
|
-
// let git 3-way merge it: clean merge -> push; real conflict -> abort,
|
|
609
|
-
// to the
|
|
610
|
-
|
|
789
|
+
// let git 3-way merge it: clean merge -> push; real conflict -> abort, roll
|
|
790
|
+
// back to preHead (the state before this operation's commit), and throw, so
|
|
791
|
+
// nothing half-applied is left behind and earlier local commits survive.
|
|
792
|
+
async _pushWithMerge(preHead) {
|
|
793
|
+
if (!preHead) throw new Error('_pushWithMerge needs the pre-operation HEAD to roll back to.');
|
|
611
794
|
try {
|
|
612
795
|
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
613
796
|
return { pushed: true, merged: false };
|
|
@@ -618,15 +801,15 @@ class OverleafGitClient {
|
|
|
618
801
|
await this._git(['-C', this.repoPath, 'merge', '--no-edit', `origin/${branch}`]);
|
|
619
802
|
} catch (mergeErr) {
|
|
620
803
|
await this._git(['-C', this.repoPath, 'merge', '--abort']).catch(() => {});
|
|
621
|
-
await this._git(['-C', this.repoPath, 'reset', '--hard',
|
|
622
|
-
const e = new Error('conflict: the file changed on Overleaf in a way that overlaps this edit.
|
|
804
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]);
|
|
805
|
+
const e = new Error('conflict: the file changed on Overleaf in a way that overlaps this edit. Run sync_project, re-read the file and retry.');
|
|
623
806
|
e.cause = mergeErr;
|
|
624
807
|
throw e;
|
|
625
808
|
}
|
|
626
809
|
try {
|
|
627
810
|
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
628
811
|
} catch (e2) {
|
|
629
|
-
await this._git(['-C', this.repoPath, 'reset', '--hard',
|
|
812
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]).catch(() => {});
|
|
630
813
|
const e = new Error('conflict: Overleaf moved again while merging; reset clean — re-read the file and retry.');
|
|
631
814
|
e.cause = e2;
|
|
632
815
|
throw e;
|
|
@@ -639,10 +822,10 @@ class OverleafGitClient {
|
|
|
639
822
|
// files: require either a matching baseSha (proves freshness) or overwrite:true
|
|
640
823
|
// (a deliberate clobber). A stale baseSha is refused, never merged.
|
|
641
824
|
async writeFile(filePath, content, opts = {}) {
|
|
642
|
-
const { baseSha, overwrite = false, commitMessage } = opts;
|
|
643
|
-
await this.
|
|
825
|
+
const { baseSha, overwrite = false, commitMessage, push = true } = opts;
|
|
826
|
+
const preHead = await this._prepareMutation(push);
|
|
644
827
|
const fullPath = path.join(this.repoPath, filePath);
|
|
645
|
-
const current = await this.getBlobSha(filePath
|
|
828
|
+
const current = await this.getBlobSha(filePath); // null if new
|
|
646
829
|
|
|
647
830
|
if (current !== null) {
|
|
648
831
|
if (baseSha != null) {
|
|
@@ -656,28 +839,10 @@ class OverleafGitClient {
|
|
|
656
839
|
|
|
657
840
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
658
841
|
await writeFile(fullPath, content, 'utf-8');
|
|
659
|
-
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
660
|
-
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
661
842
|
await this._git(['-C', this.repoPath, 'add', '--', filePath]);
|
|
662
|
-
|
|
663
|
-
await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Update ${filePath} via Claude`]);
|
|
664
|
-
} catch (e) {
|
|
665
|
-
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
|
|
666
|
-
return { pushed: false, reason: 'nothing to commit' };
|
|
667
|
-
}
|
|
668
|
-
throw e;
|
|
669
|
-
}
|
|
843
|
+
if (!(await this._commit(commitMessage || `Update ${filePath} via Claude`))) return { pushed: false, reason: 'nothing to commit' };
|
|
670
844
|
// write_file refuses on a push race rather than merging (conflict-refuse policy).
|
|
671
|
-
|
|
672
|
-
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
673
|
-
} catch (e) {
|
|
674
|
-
const branch = await this._currentBranch();
|
|
675
|
-
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
676
|
-
await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
|
|
677
|
-
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
678
|
-
throw new Error(`${filePath}: Overleaf moved while writing; refused to overwrite. Re-read and retry. (${(e.stderr || e.message || '').slice(0, 120)})`);
|
|
679
|
-
}
|
|
680
|
-
return { pushed: true };
|
|
845
|
+
return this._finishMutation(preHead, push, { refusal: `${filePath}: Overleaf moved while writing; refused to overwrite. Run sync_project, re-read and retry.` });
|
|
681
846
|
}
|
|
682
847
|
|
|
683
848
|
// Upload binary file(s) from local disk into the clone and push. Single mode:
|
|
@@ -686,7 +851,7 @@ class OverleafGitClient {
|
|
|
686
851
|
// Binary never 3-way-merges, so a push race refuses + resets like writeFile.
|
|
687
852
|
// baseSha freshness applies in single mode only; existing files in batch mode
|
|
688
853
|
// require overwrite:true.
|
|
689
|
-
async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage } = {}) {
|
|
854
|
+
async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage, push = true } = {}) {
|
|
690
855
|
let pairs;
|
|
691
856
|
const batch = Array.isArray(files);
|
|
692
857
|
if (batch) {
|
|
@@ -699,7 +864,7 @@ class OverleafGitClient {
|
|
|
699
864
|
throw new Error('upload_file needs srcPath+destPath (single) or files:[{srcPath,destPath}] (batch).');
|
|
700
865
|
}
|
|
701
866
|
|
|
702
|
-
await this.
|
|
867
|
+
const preHead = await this._prepareMutation(push);
|
|
703
868
|
const repoAbs = path.resolve(this.repoPath);
|
|
704
869
|
const resolved = [];
|
|
705
870
|
for (const { src, dest } of pairs) {
|
|
@@ -711,7 +876,7 @@ class OverleafGitClient {
|
|
|
711
876
|
if (destAbs === repoAbs || rel.startsWith('..') || path.isAbsolute(rel) || rel.split(path.sep)[0] === '.git') {
|
|
712
877
|
throw new Error(`destPath escapes the project or is not allowed: ${dest}`);
|
|
713
878
|
}
|
|
714
|
-
const current = await this.getBlobSha(rel
|
|
879
|
+
const current = await this.getBlobSha(rel);
|
|
715
880
|
if (current !== null) {
|
|
716
881
|
if (!batch && baseSha != null) {
|
|
717
882
|
if (baseSha !== current) {
|
|
@@ -729,34 +894,21 @@ class OverleafGitClient {
|
|
|
729
894
|
await copyFile(src, destAbs);
|
|
730
895
|
}
|
|
731
896
|
|
|
732
|
-
|
|
733
|
-
await this._git(['-C', this.repoPath, '
|
|
734
|
-
await this.
|
|
735
|
-
|
|
736
|
-
await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Upload ${resolved.length} file(s) via Claude`]);
|
|
737
|
-
} catch (e) {
|
|
738
|
-
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
|
|
739
|
-
return { pushed: false, reason: 'nothing to commit (identical to repo)', files: resolved.map(r => r.rel) };
|
|
740
|
-
}
|
|
741
|
-
throw e;
|
|
897
|
+
const rels = resolved.map(r => r.rel);
|
|
898
|
+
await this._git(['-C', this.repoPath, 'add', '--', ...rels]);
|
|
899
|
+
if (!(await this._commit(commitMessage || `Upload ${resolved.length} file(s) via Claude`))) {
|
|
900
|
+
return { pushed: false, reason: 'nothing to commit (identical to repo)', files: rels };
|
|
742
901
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
} catch (e) {
|
|
746
|
-
const branch = await this._currentBranch();
|
|
747
|
-
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
748
|
-
await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
|
|
749
|
-
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
750
|
-
throw new Error(`Overleaf moved while uploading; refused. Re-read and retry. (${(e.stderr || e.message || '').slice(0, 120)})`);
|
|
751
|
-
}
|
|
752
|
-
return { pushed: true, files: resolved.map(r => r.rel) };
|
|
902
|
+
const res = await this._finishMutation(preHead, push, { refusal: 'Overleaf moved while uploading; refused. Run sync_project and retry.' });
|
|
903
|
+
return { ...res, files: rels };
|
|
753
904
|
}
|
|
754
905
|
|
|
755
|
-
// Anchored, conflict-safe edit.
|
|
756
|
-
// edits)
|
|
757
|
-
// region (overlap) or the string was
|
|
758
|
-
|
|
759
|
-
|
|
906
|
+
// Anchored, conflict-safe edit. When pushing, pulls first (absorbing
|
|
907
|
+
// non-overlapping Overleaf edits); local-only edits never touch the network.
|
|
908
|
+
// A missing anchor means the region changed (overlap) or the string was
|
|
909
|
+
// wrong -> refuse, nothing written.
|
|
910
|
+
async editFile(filePath, oldString, newString, replaceAll = false, commitMessage, { push = true } = {}) {
|
|
911
|
+
const preHead = await this._prepareMutation(push);
|
|
760
912
|
const fullPath = path.join(this.repoPath, filePath);
|
|
761
913
|
let content;
|
|
762
914
|
try { content = await readFile(fullPath, 'utf-8'); }
|
|
@@ -772,39 +924,41 @@ class OverleafGitClient {
|
|
|
772
924
|
}
|
|
773
925
|
const updated = replaceAll ? parts.join(newString) : content.replace(oldString, () => newString);
|
|
774
926
|
await writeFile(fullPath, updated, 'utf-8');
|
|
775
|
-
|
|
776
|
-
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
777
|
-
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
778
927
|
await this._git(['-C', this.repoPath, 'add', '--', filePath]);
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
} catch (e) {
|
|
782
|
-
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
|
|
783
|
-
return { pushed: false, reason: 'no change (new === old)' };
|
|
784
|
-
}
|
|
785
|
-
throw e;
|
|
786
|
-
}
|
|
787
|
-
return await this._pushWithMerge();
|
|
928
|
+
if (!(await this._commit(commitMessage || `Edit ${filePath} via Claude`))) return { pushed: false, reason: 'no change (new === old)' };
|
|
929
|
+
return this._finishMutation(preHead, push, { merge: true });
|
|
788
930
|
}
|
|
789
931
|
|
|
932
|
+
// Same slicing as the bundle (sectionText): runs to the next heading of the
|
|
933
|
+
// same or higher level, covers \paragraph, and refuses an ambiguous title
|
|
934
|
+
// rather than silently returning the first match.
|
|
790
935
|
async getSectionContent(filePath, sectionTitle) {
|
|
791
|
-
|
|
792
|
-
const sections = await this.getSections(filePath);
|
|
793
|
-
const target = sections.find(s => s.title === sectionTitle);
|
|
794
|
-
if (!target) {
|
|
795
|
-
throw new Error(`Section "${sectionTitle}" not found`);
|
|
796
|
-
}
|
|
797
|
-
// The body runs until the next heading of the SAME or HIGHER level, so a
|
|
798
|
-
// \section keeps its \subsections instead of being cut at the first one.
|
|
799
|
-
const rank = { section: 1, subsection: 2, subsubsection: 3 };
|
|
800
|
-
const next = sections.find(s => s.index > target.index && rank[s.type] <= rank[target.type]);
|
|
801
|
-
const endIdx = next ? next.index : content.length;
|
|
802
|
-
return content.substring(target.index, endIdx);
|
|
936
|
+
return sectionText(await this.readFile(filePath), sectionTitle);
|
|
803
937
|
}
|
|
804
938
|
}
|
|
805
939
|
|
|
806
940
|
export { OverleafGitClient };
|
|
807
941
|
|
|
942
|
+
// settings.voiceLinter / $OVERLEAF_VOICE_LINTER override the bundled example
|
|
943
|
+
// linter, which ships with the package so voice_lint works out of the box. The
|
|
944
|
+
// example implements generic prose checks; point the setting at your own
|
|
945
|
+
// command to enforce a house style.
|
|
946
|
+
function voiceLinterCommand(config) {
|
|
947
|
+
return config.settings?.voiceLinter
|
|
948
|
+
|| process.env.OVERLEAF_VOICE_LINTER
|
|
949
|
+
|| `node ${path.join(PACKAGE_DIR, 'examples', 'voice-lint.mjs')}`;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// What a mutating tool reports: pushed, or committed locally with the count of
|
|
953
|
+
// commits still waiting for publish_changes.
|
|
954
|
+
function mutationTail(res, what) {
|
|
955
|
+
if (res.pushed) return `${what} and pushed to Overleaf${res.merged ? ' (auto-merged a concurrent Overleaf change)' : ''}.`;
|
|
956
|
+
// The full hash is what publish_changes takes as revision; the workflow
|
|
957
|
+
// itself lives in the tool descriptions, not in every reply.
|
|
958
|
+
const n = res.unpublished == null ? '' : `, ${res.unpublished} unpublished`;
|
|
959
|
+
return `${what}; committed locally at ${res.head}${n}.`;
|
|
960
|
+
}
|
|
961
|
+
|
|
808
962
|
async function getClient(projectName) {
|
|
809
963
|
const config = await loadConfig();
|
|
810
964
|
const key = pickProjectKey(config, projectName);
|
|
@@ -973,12 +1127,21 @@ const server = new Server(
|
|
|
973
1127
|
|
|
974
1128
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
975
1129
|
tools: [
|
|
1130
|
+
...[
|
|
1131
|
+
['dependency_index', 'Local static TeX dependencies and affected sections. Dynamic macros are reported as unresolved.', { changedFiles: { type: 'array', items: { type: 'string' } }, changedSymbols: { type: 'array', items: { type: 'string' } } }, []],
|
|
1132
|
+
['change_report', 'Compact local file and section changes against an in-process baseline. Omit baselineVersion to establish one.', { baselineVersion: { type: 'string' } }, []],
|
|
1133
|
+
['render_pages', 'Render explicitly selected PDF pages to cached local PNG paths. Pages are one-based; no sync or build.', { filePath: { type: 'string' }, pages: { type: 'array', items: { type: 'integer', minimum: 1 }, minItems: 1, maxItems: 20 }, dpi: { type: 'integer', minimum: 36, maximum: 300 } }, ['filePath', 'pages']],
|
|
1134
|
+
['usage_stats', 'In-process tool counts, durations, response bytes and cache hits. Stores no document content. Bytes are not billed tokens.', { reset: { type: 'boolean' } }, []],
|
|
1135
|
+
['apply_changes', 'Verify a UTF-8 multi-file batch in an isolated worktree, then commit it locally. Requires clean tracked source, HEAD baseRevision and SHA-256 baseHash per file (null for new files). No push.', { baseRevision: { type: 'string' }, changes: { type: 'array', minItems: 1, maxItems: 100, items: { type: 'object', properties: { filePath: { type: 'string' }, baseHash: { type: ['string', 'null'] }, content: { type: 'string' } }, required: ['filePath', 'baseHash', 'content'] } }, filePath: { type: 'string' }, engine: { type: 'string' }, controlled: { type: 'boolean' }, externalInputs: { type: 'array', items: { type: 'string' } }, lint: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] } }, ['baseRevision', 'changes', 'filePath']],
|
|
1136
|
+
['publish_changes', 'Verify the clean local HEAD and push every unpublished commit once. revision must equal HEAD. Reuses this session\'s verify_build PASS when no project file changed since; force rebuilds. No pull, merge or retry: if Overleaf moved, sync_project first. Needs publishing authorization.', { revision: { type: 'string' }, force: { type: 'boolean' }, filePath: { type: 'string' }, engine: { type: 'string' }, controlled: { type: 'boolean' }, externalInputs: { type: 'array', items: { type: 'string' } }, lint: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] } }, ['revision', 'filePath']],
|
|
1137
|
+
].map(([name, description, properties, required]) => ({ name, description, inputSchema: { type: 'object', properties: { projectName: { type: 'string' }, ...properties }, required } })),
|
|
976
1138
|
{
|
|
977
1139
|
name: 'get_context',
|
|
978
|
-
description: 'Read writing
|
|
1140
|
+
description: 'Read current writing and project context. Supply previousVersion to receive a compact unchanged response when content and project identity match.',
|
|
979
1141
|
inputSchema: {
|
|
980
1142
|
type: 'object',
|
|
981
1143
|
properties: {
|
|
1144
|
+
previousVersion: { type: 'string', description: 'Version returned by the previous context read.' },
|
|
982
1145
|
projectName: { type: 'string', description: 'Project key. Omit to auto-detect from current working directory.' },
|
|
983
1146
|
},
|
|
984
1147
|
},
|
|
@@ -1019,7 +1182,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1019
1182
|
},
|
|
1020
1183
|
{
|
|
1021
1184
|
name: 'configure',
|
|
1022
|
-
description: 'Set up or update overleaf-forge\'s global settings: the scaffold templates directory, the voice_lint command, and the recurring-work settings bootstrap_ssa uses (academic root, SSA subdir, default clone dir). FIRST call this with NO arguments to see the current settings and which are unset, with a question for each; ask the user those questions in turn; THEN call it again with their answers to write them to projects.json. Omit a field to leave it unchanged; pass an empty string to clear it back to the bundled default. The git token is redacted in all output. Intended right after install to configure the server conversationally.',
|
|
1185
|
+
description: 'Set up or update overleaf-forge\'s global settings: the scaffold templates directory, the voice_lint command, the edit push policy (autoPush), and the recurring-work settings bootstrap_ssa uses (academic root, SSA subdir, default clone dir). FIRST call this with NO arguments to see the current settings and which are unset, with a question for each; ask the user those questions in turn; THEN call it again with their answers to write them to projects.json. Omit a field to leave it unchanged; pass an empty string to clear it back to the bundled default. The git token is redacted in all output. Intended right after install to configure the server conversationally.',
|
|
1023
1186
|
inputSchema: {
|
|
1024
1187
|
type: 'object',
|
|
1025
1188
|
properties: {
|
|
@@ -1028,6 +1191,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1028
1191
|
ssaSubdir: { type: 'string', description: 'Subfolder name created under each course folder for new SSAs, e.g. "MY SSAs".' },
|
|
1029
1192
|
templatesDir: { type: 'string', description: 'Directory of your scaffold templates (main.tex, context-scaffold.md), overriding the bundled examples. ~ expanded. Empty string reverts to bundled.' },
|
|
1030
1193
|
voiceLinter: { type: 'string', description: 'Prose-linter command for voice_lint (takes a file path, exits non-zero on findings), overriding the bundled example. Empty string reverts to bundled.' },
|
|
1194
|
+
autoPush: { type: 'boolean', description: 'true: edit tools push immediately. false (default): they commit locally and publish_changes sends the verified batch.' },
|
|
1031
1195
|
gitToken: { type: 'string', description: 'Overleaf git token. Prefer the OVERLEAF_GIT_TOKEN env var; set here only to store it in projects.json.' },
|
|
1032
1196
|
},
|
|
1033
1197
|
},
|
|
@@ -1107,38 +1271,42 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1107
1271
|
required: ['filePath'],
|
|
1108
1272
|
},
|
|
1109
1273
|
},
|
|
1274
|
+
{
|
|
1275
|
+
name: 'sync_project',
|
|
1276
|
+
description: 'Fetch Overleaf. Fast-forwards when behind; reports unpublished commits when ahead. On divergence it changes nothing and reports both sides unless strategy is "rebase" (aborts cleanly on conflict) or "reset" (needs confirm = the reported head; tags mcp-backup/* first). Clones a missing project.',
|
|
1277
|
+
inputSchema: { type: 'object', properties: {
|
|
1278
|
+
strategy: { type: 'string', enum: ['rebase', 'reset'], description: 'Only for a diverged clone. Omit to get the report first.' },
|
|
1279
|
+
confirm: { type: 'string', description: 'For strategy "reset": the full local head SHA from the report.' },
|
|
1280
|
+
projectName: { type: 'string' },
|
|
1281
|
+
} },
|
|
1282
|
+
},
|
|
1110
1283
|
{
|
|
1111
1284
|
name: 'get_section_content',
|
|
1112
|
-
description: '
|
|
1285
|
+
description: 'Read one section (\\section to \\paragraph) by exact title from the local clone; no pull. The title must be unique in the file. bundle:true also returns the equation/figure blocks it references, matching bibliography entries and asset paths as JSON, reporting unresolved references and truncation (not a recursive TeX parser).',
|
|
1113
1286
|
inputSchema: {
|
|
1114
1287
|
type: 'object',
|
|
1115
1288
|
properties: {
|
|
1116
1289
|
filePath: { type: 'string' },
|
|
1117
1290
|
sectionTitle: { type: 'string' },
|
|
1291
|
+
bundle: { type: 'boolean', default: false, description: 'Include referenced blocks, bibliography entries and assets.' },
|
|
1292
|
+
maxChars: { type: 'integer', minimum: 2000, maximum: 64000, description: 'bundle only: response budget (default 16000).' },
|
|
1118
1293
|
projectName: { type: 'string' },
|
|
1119
1294
|
},
|
|
1120
1295
|
required: ['filePath', 'sectionTitle'],
|
|
1121
1296
|
},
|
|
1122
1297
|
},
|
|
1123
|
-
{
|
|
1124
|
-
name: 'compile_file',
|
|
1125
|
-
description: 'Compile a .tex file locally with LuaLaTeX (default), XeLaTeX, or pdfLaTeX. Pulls before compiling. ALWAYS run this after write_file before declaring work done — silent build breakage is the most common failure mode.',
|
|
1126
|
-
inputSchema: {
|
|
1127
|
-
type: 'object',
|
|
1128
|
-
properties: {
|
|
1129
|
-
filePath: { type: 'string' },
|
|
1130
|
-
engine: { type: 'string', description: 'pdflatex | xelatex | lualatex (default lualatex)' },
|
|
1131
|
-
projectName: { type: 'string' },
|
|
1132
|
-
},
|
|
1133
|
-
required: ['filePath'],
|
|
1134
|
-
},
|
|
1135
|
-
},
|
|
1136
1298
|
{
|
|
1137
1299
|
name: 'verify_build',
|
|
1138
|
-
description: '
|
|
1300
|
+
description: 'Build the entrypoint and return PASS/FAIL. PASS needs a PDF, zero LaTeX errors and zero undefined references and citations (and zero findings with lint); box warnings do not fail. Default: clean from-scratch final gate, reusing an unchanged eligible PASS unless force. clean:false: quick incremental rebuild for intermediate checks. Local only.',
|
|
1139
1301
|
inputSchema: {
|
|
1140
1302
|
type: 'object',
|
|
1141
1303
|
properties: {
|
|
1304
|
+
clean: { type: 'boolean', default: true, description: 'false = quick incremental rebuild that always recompiles (intermediate checks); true = from-scratch final gate.' },
|
|
1305
|
+
lint: { description: 'Also run the voice linter as part of the gate: true for every .tex file, or an array of paths. Findings fail the verdict.', anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] },
|
|
1306
|
+
controlled: { type: 'boolean', default: false, description: 'Ignore all latexmk rc files and disable shell escape. Opt in only when the project supports this mode.' },
|
|
1307
|
+
externalInputs: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of additional build inputs to hash.' },
|
|
1308
|
+
verbose: { type: 'boolean', default: false, description: 'Include a bounded log tail; full log remains on disk.' },
|
|
1309
|
+
force: { type: 'boolean', default: false, description: 'Ignore a cached verification and rebuild.' },
|
|
1142
1310
|
filePath: { type: 'string', description: 'The entrypoint, usually main.tex.' },
|
|
1143
1311
|
engine: { type: 'string', description: 'pdflatex | xelatex | lualatex (default lualatex).' },
|
|
1144
1312
|
projectName: { type: 'string' },
|
|
@@ -1148,7 +1316,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1148
1316
|
},
|
|
1149
1317
|
{
|
|
1150
1318
|
name: 'edit_file',
|
|
1151
|
-
description: '
|
|
1319
|
+
description: 'Anchored edit: replace oldString (must match once unless replaceAll) with newString and commit. A missing anchor means the region changed, so the edit refuses instead of clobbering it. Prefer over write_file for existing files. Commits locally unless push.',
|
|
1152
1320
|
inputSchema: {
|
|
1153
1321
|
type: 'object',
|
|
1154
1322
|
properties: {
|
|
@@ -1157,6 +1325,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1157
1325
|
newString: { type: 'string', description: 'Replacement text.' },
|
|
1158
1326
|
replaceAll: { type: 'boolean', description: 'Replace every occurrence (default false; otherwise oldString must be unique).' },
|
|
1159
1327
|
commitMessage: { type: 'string' },
|
|
1328
|
+
push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
|
|
1160
1329
|
projectName: { type: 'string' },
|
|
1161
1330
|
},
|
|
1162
1331
|
required: ['filePath', 'oldString', 'newString'],
|
|
@@ -1164,7 +1333,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1164
1333
|
},
|
|
1165
1334
|
{
|
|
1166
1335
|
name: 'write_file',
|
|
1167
|
-
description: 'Create a
|
|
1336
|
+
description: 'Create a file or replace one wholesale, and commit. Replacing needs baseSha from read_file (a stale one is refused) or overwrite:true. Prefer edit_file for changes. Commits locally unless push.',
|
|
1168
1337
|
inputSchema: {
|
|
1169
1338
|
type: 'object',
|
|
1170
1339
|
properties: {
|
|
@@ -1173,6 +1342,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1173
1342
|
baseSha: { type: 'string', description: 'The baseSha from read_file for this file. Required to overwrite an existing file safely; if Overleaf moved since, the write is refused.' },
|
|
1174
1343
|
overwrite: { type: 'boolean', description: 'Force-overwrite an existing file without a baseSha (deliberate full replacement). Ignored for new files.' },
|
|
1175
1344
|
commitMessage: { type: 'string' },
|
|
1345
|
+
push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
|
|
1176
1346
|
projectName: { type: 'string' },
|
|
1177
1347
|
},
|
|
1178
1348
|
required: ['filePath', 'content'],
|
|
@@ -1180,7 +1350,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1180
1350
|
},
|
|
1181
1351
|
{
|
|
1182
1352
|
name: 'upload_file',
|
|
1183
|
-
description: '
|
|
1353
|
+
description: 'Copy binary file(s) such as figures from a local path into the project and commit. Single: srcPath + destPath; batch: files[] in one commit. Existing destinations need baseSha (single) or overwrite:true. Commits locally unless push.',
|
|
1184
1354
|
inputSchema: {
|
|
1185
1355
|
type: 'object',
|
|
1186
1356
|
properties: {
|
|
@@ -1194,6 +1364,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1194
1364
|
baseSha: { type: 'string', description: 'Single-file mode only: baseSha from read_file; a stale value is refused. Ignored in batch.' },
|
|
1195
1365
|
overwrite: { type: 'boolean', description: 'Replace existing dest file(s). Required to overwrite in batch mode.' },
|
|
1196
1366
|
commitMessage: { type: 'string' },
|
|
1367
|
+
push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
|
|
1197
1368
|
projectName: { type: 'string' },
|
|
1198
1369
|
},
|
|
1199
1370
|
},
|
|
@@ -1215,12 +1386,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1215
1386
|
},
|
|
1216
1387
|
{
|
|
1217
1388
|
name: 'add_citation',
|
|
1218
|
-
description: 'Append a BibTeX entry (raw @type{key, ...} string) to refs.bib and push. Refuses if the key already exists. Creates refs.bib if absent.',
|
|
1389
|
+
description: 'Append a BibTeX entry (raw @type{key, ...} string) to refs.bib and commit (local by default; see push). Refuses if the key already exists. Creates refs.bib if absent.',
|
|
1219
1390
|
inputSchema: {
|
|
1220
1391
|
type: 'object',
|
|
1221
1392
|
properties: {
|
|
1222
1393
|
entry: { type: 'string', description: 'A complete BibTeX entry, e.g. @article{key, title={...}, ...}.' },
|
|
1223
1394
|
commitMessage: { type: 'string' },
|
|
1395
|
+
push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
|
|
1224
1396
|
projectName: { type: 'string' },
|
|
1225
1397
|
},
|
|
1226
1398
|
required: ['entry'],
|
|
@@ -1238,12 +1410,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1238
1410
|
},
|
|
1239
1411
|
{
|
|
1240
1412
|
name: 'restore',
|
|
1241
|
-
description: 'Roll back to a checkpoint: re-applies the snapshot\'s file tree as a NEW commit on top of history
|
|
1242
|
-
inputSchema: { type: 'object', properties: { label: { type: 'string' }, projectName: { type: 'string' } }, required: ['label'] },
|
|
1413
|
+
description: 'Roll back to a checkpoint: re-applies the snapshot\'s file tree as a NEW commit on top of history (no force-push, no history rewrite); intervening commits are preserved. Local by default; see push.',
|
|
1414
|
+
inputSchema: { type: 'object', properties: { label: { type: 'string' }, push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' }, projectName: { type: 'string' } }, required: ['label'] },
|
|
1243
1415
|
},
|
|
1244
1416
|
{
|
|
1245
1417
|
name: 'voice_lint',
|
|
1246
|
-
description: '
|
|
1418
|
+
description: 'Run the prose linter (settings.voiceLinter, else the bundled example) on a local .tex file; never pulls. Advisory on its own; verify_build with lint makes findings fail the gate.',
|
|
1247
1419
|
inputSchema: {
|
|
1248
1420
|
type: 'object',
|
|
1249
1421
|
properties: { filePath: { type: 'string' }, projectName: { type: 'string' } },
|
|
@@ -1261,7 +1433,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1261
1433
|
],
|
|
1262
1434
|
}));
|
|
1263
1435
|
|
|
1264
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1436
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => observeTool(request.params.name, async () => {
|
|
1265
1437
|
try {
|
|
1266
1438
|
const { name, arguments: args } = request.params;
|
|
1267
1439
|
|
|
@@ -1501,7 +1673,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1501
1673
|
const key = pickProjectKey(config, args.projectName);
|
|
1502
1674
|
const project = config.projects[key];
|
|
1503
1675
|
let guidelines = '';
|
|
1504
|
-
|
|
1676
|
+
const guidelinesPath = resolveGuidelinesPath({ dataHome: DATA_HOME, packageDir: PACKAGE_DIR, exists: existsSync });
|
|
1677
|
+
try { guidelines = await readFile(guidelinesPath, 'utf-8'); }
|
|
1505
1678
|
catch { guidelines = '(writing-guidelines.md missing from OverleafMCP folder)'; }
|
|
1506
1679
|
const ctx = await readContext(key, project);
|
|
1507
1680
|
const text = [
|
|
@@ -1522,7 +1695,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1522
1695
|
``,
|
|
1523
1696
|
ctx.body,
|
|
1524
1697
|
].join('\n');
|
|
1525
|
-
|
|
1698
|
+
const v = versionedContext(key,text,args.previousVersion);
|
|
1699
|
+
return { content: [{ type: 'text', text: `Context version: ${v.version}\n${v.text}` }], structuredContent: { version:v.version, unchanged:v.unchanged, projectName:key } };
|
|
1526
1700
|
}
|
|
1527
1701
|
|
|
1528
1702
|
case 'list_files': {
|
|
@@ -1536,7 +1710,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1536
1710
|
const content = await client.readFile(args.filePath);
|
|
1537
1711
|
const baseSha = await client.getBlobSha(args.filePath, { pull: false });
|
|
1538
1712
|
const header = `<!-- overleaf-mcp baseSha: ${baseSha || 'none'} (pass as baseSha to write_file to guard against clobbering Overleaf edits) -->\n`;
|
|
1539
|
-
|
|
1713
|
+
const { stdout } = await client._git(['-C', client.repoPath, 'rev-parse', 'HEAD']);
|
|
1714
|
+
return { content: [{ type: 'text', text: header + content }], structuredContent: { baseSha, baseRevision: stdout.trim(), contentHash: createHash('sha256').update(content).digest('hex') } };
|
|
1540
1715
|
}
|
|
1541
1716
|
|
|
1542
1717
|
case 'get_sections': {
|
|
@@ -1547,65 +1722,114 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1547
1722
|
|
|
1548
1723
|
case 'get_section_content': {
|
|
1549
1724
|
const { client } = await getClient(args.projectName);
|
|
1725
|
+
if (args.bundle) {
|
|
1726
|
+
await client.requireLocal();
|
|
1727
|
+
const result = await sectionBundle(client.repoPath, args.filePath, args.sectionTitle, args.maxChars);
|
|
1728
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
1729
|
+
}
|
|
1550
1730
|
const content = await client.getSectionContent(args.filePath, args.sectionTitle);
|
|
1551
1731
|
return { content: [{ type: 'text', text: content }] };
|
|
1552
1732
|
}
|
|
1553
1733
|
|
|
1554
|
-
case '
|
|
1734
|
+
case 'sync_project': {
|
|
1555
1735
|
const { client } = await getClient(args.projectName);
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1736
|
+
const result = await client.syncProject({ strategy: args.strategy, confirm: args.confirm });
|
|
1737
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
|
|
1738
|
+
}
|
|
1739
|
+
case 'usage_stats': {
|
|
1740
|
+
const result = usageStats(args);
|
|
1741
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
1742
|
+
}
|
|
1743
|
+
case 'dependency_index':
|
|
1744
|
+
case 'change_report':
|
|
1745
|
+
case 'render_pages': {
|
|
1746
|
+
const { client } = await getClient(args.projectName);
|
|
1747
|
+
await client.requireLocal();
|
|
1748
|
+
const result = name === 'dependency_index' ? await dependencyIndex(client.repoPath, args)
|
|
1749
|
+
: name === 'change_report' ? await changeReport(client.repoPath, args.baselineVersion)
|
|
1750
|
+
: await renderPages(client.repoPath, args);
|
|
1751
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
1752
|
+
}
|
|
1753
|
+
case 'apply_changes':
|
|
1754
|
+
case 'publish_changes': {
|
|
1755
|
+
const config = await loadConfig();
|
|
1756
|
+
const { client } = await getClient(args.projectName);
|
|
1757
|
+
await client.requireLocal();
|
|
1758
|
+
const verify = async root => {
|
|
1759
|
+
const candidate = new OverleafGitClient(client.projectId, client.gitToken, root);
|
|
1760
|
+
return candidate.verifyBuild(args.filePath, args.engine || 'lualatex', {
|
|
1761
|
+
controlled: args.controlled === true,
|
|
1762
|
+
externalInputs: args.externalInputs,
|
|
1763
|
+
lint: args.lint,
|
|
1764
|
+
lintCommand: voiceLinterCommand(config),
|
|
1765
|
+
reuseRecentPass: name === 'publish_changes' && args.force !== true,
|
|
1766
|
+
});
|
|
1767
|
+
};
|
|
1768
|
+
const result = name === 'apply_changes' ? await applyChanges(client.repoPath, args, verify)
|
|
1769
|
+
: await publishChanges(client.repoPath, args, verify, async (root, revision, branch) => {
|
|
1770
|
+
try {
|
|
1771
|
+
await client._git(['-C', root, 'push', 'origin', `${revision}:refs/heads/${branch}`], { auth: true });
|
|
1772
|
+
} catch (e) {
|
|
1773
|
+
// A rejected push means Overleaf moved since the last sync; name
|
|
1774
|
+
// the recovery instead of surfacing raw git plumbing.
|
|
1775
|
+
if (/rejected|fetch first|non-fast-forward/i.test(e.stderr || '')) {
|
|
1776
|
+
throw new Error('Overleaf has commits this clone lacks, so the push was refused and nothing was published. Run sync_project to see both sides, resolve with strategy "rebase", verify again, then publish the new HEAD.');
|
|
1777
|
+
}
|
|
1778
|
+
throw e;
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1781
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
1564
1782
|
}
|
|
1565
|
-
|
|
1566
1783
|
case 'verify_build': {
|
|
1784
|
+
const config = await loadConfig();
|
|
1567
1785
|
const { client } = await getClient(args.projectName);
|
|
1568
|
-
const
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
parts.push(
|
|
1581
|
-
|
|
1786
|
+
const opts = { ...args, lintCommand: voiceLinterCommand(config) };
|
|
1787
|
+
const v = args.clean === false
|
|
1788
|
+
? await client.compileFile(args.filePath, args.engine || 'lualatex', opts)
|
|
1789
|
+
: await client.verifyBuild(args.filePath, args.engine || 'lualatex', opts);
|
|
1790
|
+
// A PASS already means zero errors and zero undefined references and
|
|
1791
|
+
// citations, so only box warnings and reuse are worth reporting. A FAIL
|
|
1792
|
+
// carries the counts, the first offenders and where the full log is.
|
|
1793
|
+
const boxes = v.overfullCount || v.underfullCount ? `; ${v.overfullCount} overfull / ${v.underfullCount} underfull boxes` : '';
|
|
1794
|
+
const parts = v.pass
|
|
1795
|
+
? [`PASS: ${v.pageCount ?? '?'} pages${boxes}${v.reused ? ' (reused unchanged verification)' : ''}.`]
|
|
1796
|
+
: [`FAIL: ${v.pageCount ?? '?'} pages; ${v.errors.length} errors; ${v.undefinedRefs.length} undefined references; ${v.undefinedCitations.length} undefined citations${boxes}. Log: ${path.relative(client.repoPath, v.logPath)}`,
|
|
1797
|
+
...v.errors.slice(0,5),...v.undefinedRefs.slice(0,5),...v.undefinedCitations.slice(0,5)];
|
|
1798
|
+
if (v.lint) parts.push(v.lint.clean ? `Voice lint: clean (${v.lint.results.length} file(s)).` : `Voice lint findings:\n${v.lint.results.filter(r => !r.clean).map(r => `${r.file}:\n${r.findings}`).join('\n')}`);
|
|
1799
|
+
if (args.verbose) parts.push(v.tail);
|
|
1800
|
+
return { content:[{type:'text',text:parts.join('\n')}], structuredContent: { pass: v.pass, pageCount: v.pageCount, reused: v.reused, cacheEligible: v.cacheEligible, errors: v.errors.slice(0,5), logPath: v.logPath, lintClean: v.lint ? v.lint.clean : null } };
|
|
1582
1801
|
}
|
|
1583
1802
|
|
|
1584
1803
|
case 'edit_file': {
|
|
1804
|
+
const config = await loadConfig();
|
|
1585
1805
|
const { client } = await getClient(args.projectName);
|
|
1586
|
-
const res = await client.editFile(args.filePath, args.oldString, args.newString, args.replaceAll || false, args.commitMessage);
|
|
1587
|
-
const tail = res.
|
|
1588
|
-
? `
|
|
1589
|
-
: `
|
|
1806
|
+
const res = await client.editFile(args.filePath, args.oldString, args.newString, args.replaceAll || false, args.commitMessage, { push: resolvePush(config.settings, args) });
|
|
1807
|
+
const tail = res.reason
|
|
1808
|
+
? `No change applied to ${args.filePath} (${res.reason}).`
|
|
1809
|
+
: mutationTail(res, `Edited ${args.filePath}`);
|
|
1590
1810
|
return { content: [{ type: 'text', text: tail }] };
|
|
1591
1811
|
}
|
|
1592
1812
|
|
|
1593
1813
|
case 'write_file': {
|
|
1814
|
+
const config = await loadConfig();
|
|
1594
1815
|
const { client } = await getClient(args.projectName);
|
|
1595
1816
|
const res = await client.writeFile(args.filePath, args.content, {
|
|
1596
1817
|
baseSha: args.baseSha,
|
|
1597
1818
|
overwrite: args.overwrite,
|
|
1598
1819
|
commitMessage: args.commitMessage,
|
|
1820
|
+
push: resolvePush(config.settings, args),
|
|
1599
1821
|
});
|
|
1600
|
-
const tail = res.
|
|
1601
|
-
? `
|
|
1602
|
-
: `
|
|
1822
|
+
const tail = res.reason
|
|
1823
|
+
? `No change detected for ${args.filePath} (${res.reason}).`
|
|
1824
|
+
: mutationTail(res, `Wrote ${args.filePath}`);
|
|
1603
1825
|
return { content: [{ type: 'text', text: tail }] };
|
|
1604
1826
|
}
|
|
1605
1827
|
|
|
1606
1828
|
case 'upload_file': {
|
|
1829
|
+
const config = await loadConfig();
|
|
1607
1830
|
const { client } = await getClient(args.projectName);
|
|
1608
1831
|
const res = await client.uploadFile({
|
|
1832
|
+
push: resolvePush(config.settings, args),
|
|
1609
1833
|
srcPath: args.srcPath,
|
|
1610
1834
|
destPath: args.destPath,
|
|
1611
1835
|
files: args.files,
|
|
@@ -1613,9 +1837,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1613
1837
|
overwrite: args.overwrite,
|
|
1614
1838
|
commitMessage: args.commitMessage,
|
|
1615
1839
|
});
|
|
1616
|
-
const tail = res.
|
|
1617
|
-
? `
|
|
1618
|
-
: `
|
|
1840
|
+
const tail = res.reason
|
|
1841
|
+
? `No upload performed (${res.reason}).`
|
|
1842
|
+
: `${mutationTail(res, `Uploaded ${res.files.length} file(s): ${res.files.join(', ')}`)} Reference each figure with \\includegraphics{...} via edit_file.`;
|
|
1619
1843
|
return { content: [{ type: 'text', text: tail }] };
|
|
1620
1844
|
}
|
|
1621
1845
|
|
|
@@ -1628,9 +1852,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1628
1852
|
}
|
|
1629
1853
|
|
|
1630
1854
|
case 'add_citation': {
|
|
1855
|
+
const config = await loadConfig();
|
|
1631
1856
|
const { client } = await getClient(args.projectName);
|
|
1632
|
-
const res = await client.addCitation({ entry: args.entry, commitMessage: args.commitMessage });
|
|
1633
|
-
return { content: [{ type: 'text', text: res.
|
|
1857
|
+
const res = await client.addCitation({ entry: args.entry, commitMessage: args.commitMessage, push: resolvePush(config.settings, args) });
|
|
1858
|
+
return { content: [{ type: 'text', text: res.reason ? `No change for "${res.key}" (${res.reason}).` : mutationTail(res, `Added citation "${res.key}" to refs.bib`) }] };
|
|
1634
1859
|
}
|
|
1635
1860
|
|
|
1636
1861
|
case 'cite_lint': {
|
|
@@ -1649,22 +1874,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1649
1874
|
}
|
|
1650
1875
|
|
|
1651
1876
|
case 'restore': {
|
|
1877
|
+
const config = await loadConfig();
|
|
1652
1878
|
const { client } = await getClient(args.projectName);
|
|
1653
|
-
const r = await client.restore(args.label);
|
|
1654
|
-
return { content: [{ type: 'text', text: `Restored "${r.label}"
|
|
1879
|
+
const r = await client.restore(args.label, { push: resolvePush(config.settings, args) });
|
|
1880
|
+
return { content: [{ type: 'text', text: `${mutationTail(r, `Restored "${r.label}" as a forward commit`)} Run verify_build to confirm.` }] };
|
|
1655
1881
|
}
|
|
1656
1882
|
|
|
1657
1883
|
case 'voice_lint': {
|
|
1658
1884
|
const config = await loadConfig();
|
|
1659
1885
|
const { client } = await getClient(args.projectName);
|
|
1660
|
-
|
|
1661
|
-
// example linter, which ships with the package so voice_lint works out
|
|
1662
|
-
// of the box. The example implements generic prose checks; point the
|
|
1663
|
-
// setting at your own command to enforce a house style.
|
|
1664
|
-
const command = config.settings?.voiceLinter
|
|
1665
|
-
|| process.env.OVERLEAF_VOICE_LINTER
|
|
1666
|
-
|| `node ${path.join(PACKAGE_DIR, 'examples', 'voice-lint.mjs')}`;
|
|
1667
|
-
const r = await client.voiceLint(args.filePath, { command });
|
|
1886
|
+
const r = await client.voiceLint(args.filePath, { command: voiceLinterCommand(config) });
|
|
1668
1887
|
return { content: [{ type: 'text', text: r.clean ? `✓ voice OK — ${args.filePath}${r.findings ? `\n${r.findings}` : ''}` : `voice findings in ${args.filePath}:\n${r.findings}` }] };
|
|
1669
1888
|
}
|
|
1670
1889
|
|
|
@@ -1693,14 +1912,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1693
1912
|
throw new Error(`Unknown tool: ${name}`);
|
|
1694
1913
|
}
|
|
1695
1914
|
} catch (error) {
|
|
1696
|
-
|
|
1697
|
-
const msg = String(error?.message ?? error).replace(/git:[^@\s/]+@/g, 'git:***@');
|
|
1698
|
-
return {
|
|
1699
|
-
content: [{ type: 'text', text: `Error: ${msg}` }],
|
|
1700
|
-
isError: true,
|
|
1701
|
-
};
|
|
1915
|
+
return toolError(error);
|
|
1702
1916
|
}
|
|
1703
|
-
});
|
|
1917
|
+
})());
|
|
1704
1918
|
|
|
1705
1919
|
async function main() {
|
|
1706
1920
|
const transport = new StdioServerTransport();
|