overleaf-forge 2.9.1 → 2.11.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 +31 -14
- package/dependency-index.js +189 -0
- package/efficiency.js +115 -0
- package/overleaf-mcp-server.js +437 -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,14 @@ 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 buildQueues = new Map();
|
|
16
24
|
|
|
17
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
26
|
const __dirname = path.dirname(__filename);
|
|
@@ -85,10 +93,20 @@ const CONFIG_PATH = path.join(DATA_HOME, 'projects.json');
|
|
|
85
93
|
const CONTEXTS_DIR = path.join(DATA_HOME, 'contexts');
|
|
86
94
|
const DEFAULT_REPO_DIR = path.join(DATA_HOME, 'repos');
|
|
87
95
|
const BUNDLED_TEMPLATES_DIR = path.join(PACKAGE_DIR, 'templates');
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
// Guidelines resolve personal-first:
|
|
97
|
+
// 1. <dataHome>/writing-guidelines.local.md (gitignored; never ships)
|
|
98
|
+
// 2. <dataHome>/writing-guidelines.md (user copy, when dataHome is not the package)
|
|
99
|
+
// 3. <packageDir>/writing-guidelines.md (bundled generic default)
|
|
100
|
+
// The .local name is what keeps a personal copy distinct when dataHome IS the
|
|
101
|
+
// package dir (a local clone with projects.json), where 2 and 3 are one file.
|
|
102
|
+
// Resolved per call so creating or deleting the local file needs no restart.
|
|
103
|
+
export function resolveGuidelinesPath({ dataHome, packageDir, exists }) {
|
|
104
|
+
const local = path.join(dataHome, 'writing-guidelines.local.md');
|
|
105
|
+
if (exists(local)) return local;
|
|
106
|
+
const user = path.join(dataHome, 'writing-guidelines.md');
|
|
107
|
+
if (exists(user)) return user;
|
|
108
|
+
return path.join(packageDir, 'writing-guidelines.md');
|
|
109
|
+
}
|
|
92
110
|
|
|
93
111
|
// Where scaffold templates (main.tex skeleton, context-scaffold.md) are read.
|
|
94
112
|
// Precedence: settings.templatesDir → $OVERLEAF_MCP_TEMPLATES → ~/.overleaf-mcp/
|
|
@@ -197,7 +215,8 @@ const SETTING_HELP = {
|
|
|
197
215
|
ssaSubdir: 'What subfolder name should new SSAs go under inside each course folder? (e.g. "MY SSAs")',
|
|
198
216
|
templatesDir: 'Use your own scaffold templates? Give the directory holding main.tex / context-scaffold.md (blank keeps the bundled examples).',
|
|
199
217
|
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
|
-
|
|
218
|
+
autoPush: 'Should edit tools push to Overleaf immediately (true), or commit locally until publish_changes sends a verified batch (false, the default)?',
|
|
219
|
+
gitToken: 'Overleaf git token (prefer the OVERLEAF_GIT_TOKEN env var; set here only if you must store it in projects.json).',
|
|
201
220
|
};
|
|
202
221
|
const SETTING_KEYS = Object.keys(SETTING_HELP);
|
|
203
222
|
const SETTING_PATHY = new Set(['repoDir', 'academicRoot', 'templatesDir']);
|
|
@@ -218,6 +237,14 @@ export function mergeSettings(current, args, homeDir) {
|
|
|
218
237
|
return { settings, provided };
|
|
219
238
|
}
|
|
220
239
|
|
|
240
|
+
// Whether a mutating tool pushes to Overleaf. An explicit per-call `push` wins,
|
|
241
|
+
// then settings.autoPush; the default is false, so edits stay local commits
|
|
242
|
+
// until publish_changes verifies and sends them in one deliberate step.
|
|
243
|
+
export function resolvePush(settings, args) {
|
|
244
|
+
if (typeof args?.push === 'boolean') return args.push;
|
|
245
|
+
return settings?.autoPush === true;
|
|
246
|
+
}
|
|
247
|
+
|
|
221
248
|
// Default project resolution:
|
|
222
249
|
// 1. explicit projectName argument
|
|
223
250
|
// 2. project whose `cwd` is a prefix of SESSION_CWD (longest match wins)
|
|
@@ -321,23 +348,71 @@ class OverleafGitClient {
|
|
|
321
348
|
}
|
|
322
349
|
// Repair older clones that embedded the token in the remote URL.
|
|
323
350
|
await this._git(['-C', this.repoPath, 'remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
|
|
351
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'pull', '--ff-only'], { auth: true });
|
|
352
|
+
return stdout;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async requireLocal() {
|
|
356
|
+
if (!(await this._hasRepo())) throw Object.assign(new Error('Local clone missing; call sync_project explicitly.'), { code: 'LOCAL_CLONE_MISSING' });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Mutations that push absorb remote edits first; local-only mutations stay
|
|
360
|
+
// off the network entirely, like reads.
|
|
361
|
+
async _prepareMutation(push) {
|
|
362
|
+
if (push) await this.cloneOrPull();
|
|
363
|
+
else await this.requireLocal();
|
|
364
|
+
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
365
|
+
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
366
|
+
return this._head();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async _head() {
|
|
370
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', 'HEAD']);
|
|
371
|
+
return stdout.trim();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// HEAD plus the number of local commits not yet on the remote-tracking branch.
|
|
375
|
+
// unpublished is null when there is no tracking ref to compare against.
|
|
376
|
+
async pendingState() {
|
|
377
|
+
const head = await this._head();
|
|
378
|
+
const branch = await this._currentBranch();
|
|
379
|
+
let unpublished = null;
|
|
324
380
|
try {
|
|
325
|
-
const { stdout } = await this._git(['-C', this.repoPath, '
|
|
326
|
-
|
|
327
|
-
} catch {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
381
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'rev-list', '--count', `origin/${branch}..HEAD`]);
|
|
382
|
+
unpublished = Number(stdout.trim());
|
|
383
|
+
} catch { /* no tracking ref */ }
|
|
384
|
+
return { head, unpublished };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Finish a mutation whose commit is already made. push:false keeps it local
|
|
388
|
+
// for publish_changes. A refused push rolls back to preHead, this operation's
|
|
389
|
+
// own starting point: only its commit is undone, and earlier unpublished
|
|
390
|
+
// commits survive. (Resetting to origin/<branch> would silently drop them.)
|
|
391
|
+
async _finishMutation(preHead, push, { merge = false, refusal }) {
|
|
392
|
+
if (!push) return { pushed: false, committed: true, ...(await this.pendingState()) };
|
|
393
|
+
if (merge) return this._pushWithMerge(preHead);
|
|
394
|
+
try {
|
|
395
|
+
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
396
|
+
} catch (e) {
|
|
397
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]).catch(() => {});
|
|
398
|
+
throw new Error(`${refusal} (${(e.stderr || e.message || '').slice(0, 120)})`);
|
|
399
|
+
}
|
|
400
|
+
return { pushed: true };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// git commit that reports "nothing to commit" as a value instead of throwing.
|
|
404
|
+
async _commit(message) {
|
|
405
|
+
try {
|
|
406
|
+
await this._git(['-C', this.repoPath, 'commit', '-m', message]);
|
|
407
|
+
return true;
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) return false;
|
|
410
|
+
throw e;
|
|
336
411
|
}
|
|
337
412
|
}
|
|
338
413
|
|
|
339
414
|
async listFiles(extension = '.tex') {
|
|
340
|
-
await this.
|
|
415
|
+
await this.requireLocal();
|
|
341
416
|
const out = [];
|
|
342
417
|
const walk = async (dir) => {
|
|
343
418
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -355,14 +430,16 @@ class OverleafGitClient {
|
|
|
355
430
|
}
|
|
356
431
|
|
|
357
432
|
async readFile(filePath) {
|
|
358
|
-
await this.
|
|
359
|
-
const
|
|
433
|
+
await this.requireLocal();
|
|
434
|
+
const root = realpathSync(this.repoPath);
|
|
435
|
+
const fullPath = realpathSync(path.resolve(root, filePath));
|
|
436
|
+
if (!fullPath.startsWith(root + path.sep)) throw Object.assign(new Error('File must be inside the project.'), { code: 'INVALID_INPUT' });
|
|
360
437
|
return await readFile(fullPath, 'utf-8');
|
|
361
438
|
}
|
|
362
439
|
|
|
363
440
|
// git blob SHA of a file at the current tip; null if the file isn't tracked.
|
|
364
|
-
async getBlobSha(filePath
|
|
365
|
-
|
|
441
|
+
async getBlobSha(filePath) {
|
|
442
|
+
await this.requireLocal();
|
|
366
443
|
try {
|
|
367
444
|
const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', `HEAD:${filePath}`]);
|
|
368
445
|
return stdout.trim();
|
|
@@ -387,63 +464,167 @@ class OverleafGitClient {
|
|
|
387
464
|
// Run latexmk from the repo root (so the project's .latexmkrc -- shell-escape,
|
|
388
465
|
// the python@3.13 PATH fix for minted, $pdf_mode -- applies, and refs/citations/
|
|
389
466
|
// reruns resolve). clean:true adds -gg to force a complete from-scratch rebuild.
|
|
390
|
-
async _runLatexmk(filePath, engine = 'lualatex', { clean = false } = {}) {
|
|
391
|
-
|
|
467
|
+
async _runLatexmk(filePath, engine = 'lualatex', { clean = false, controlled = false } = {}) {
|
|
468
|
+
const full = path.resolve(this.repoPath, filePath);
|
|
469
|
+
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.');
|
|
470
|
+
if (!(await this._hasRepo())) throw new Error('Local clone missing; call sync_project explicitly before building.');
|
|
392
471
|
const engineFlag = { pdflatex: '-pdf', xelatex: '-xelatex', lualatex: '-lualatex' }[engine];
|
|
393
472
|
if (!engineFlag) {
|
|
394
473
|
throw new Error(`Invalid engine "${engine}". Choose from: pdflatex, xelatex, lualatex`);
|
|
395
474
|
}
|
|
396
475
|
const texbin = '/Library/TeX/texbin';
|
|
397
476
|
const env = { ...process.env, PATH: `${texbin}:${process.env.PATH || ''}` };
|
|
398
|
-
const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error'];
|
|
477
|
+
const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error', '-recorder'];
|
|
478
|
+
// -norc must precede all other options so no user or project Perl config runs.
|
|
479
|
+
if (controlled) args.unshift('-norc', '-no-shell-escape');
|
|
399
480
|
if (clean) args.push('-gg');
|
|
400
481
|
args.push(filePath);
|
|
482
|
+
let commandFailed = false;
|
|
401
483
|
const { stdout, stderr } = await execFile(
|
|
402
484
|
path.join(texbin, 'latexmk'), args,
|
|
403
485
|
{ cwd: this.repoPath, timeout: 180000, maxBuffer: 20 * 1024 * 1024, env }
|
|
404
|
-
).catch(e =>
|
|
486
|
+
).catch(e => { commandFailed = true; return { stdout: e.stdout || '', stderr: e.stderr || e.message }; });
|
|
405
487
|
const pdfPath = path.join(this.repoPath, filePath.replace(/\.tex$/, '.pdf'));
|
|
406
488
|
let pdfExists = false;
|
|
407
489
|
try { await access(pdfPath); pdfExists = true; } catch { /* no pdf */ }
|
|
408
|
-
return { stdout, stderr, log: `${stdout}\n${stderr}`, pdfPath: pdfExists ? pdfPath : null };
|
|
490
|
+
return { stdout, stderr, commandFailed, log: `${stdout}\n${stderr}`, pdfPath: !commandFailed && pdfExists ? pdfPath : null };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async compileFile(filePath, engine = 'lualatex', options = {}) {
|
|
494
|
+
return this.verifyBuild(filePath, engine, { ...options, force: true, clean: false });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Explicit sync. Fetches, then acts on the ahead/behind relation:
|
|
498
|
+
// behind only -> fast-forward
|
|
499
|
+
// ahead only / equal -> nothing to do (ahead = unpublished local commits)
|
|
500
|
+
// diverged -> report both sides and change nothing, unless a
|
|
501
|
+
// strategy is chosen:
|
|
502
|
+
// 'rebase' replays local commits onto the remote; a conflict aborts back
|
|
503
|
+
// to the untouched pre-sync state.
|
|
504
|
+
// 'reset' discards local work to match the remote. Requires confirm to
|
|
505
|
+
// equal the reported local HEAD (proof the report was read), and
|
|
506
|
+
// tags the old HEAD and any uncommitted edits as mcp-backup/*
|
|
507
|
+
// first, so nothing becomes unrecoverable.
|
|
508
|
+
async syncProject({ strategy, confirm } = {}) {
|
|
509
|
+
if (!(await this._hasRepo())) {
|
|
510
|
+
await this.cloneOrPull();
|
|
511
|
+
return { state: 'cloned', ...(await this.pendingState()) };
|
|
512
|
+
}
|
|
513
|
+
if (strategy !== undefined && strategy !== 'rebase' && strategy !== 'reset') {
|
|
514
|
+
throw new Error(`Unknown strategy "${strategy}". Use "rebase" or "reset".`);
|
|
515
|
+
}
|
|
516
|
+
const g = (args, opts) => this._git(['-C', this.repoPath, ...args], opts);
|
|
517
|
+
await g(['remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
|
|
518
|
+
await g(['fetch', 'origin'], { auth: true });
|
|
519
|
+
// rebase and stash create write commits, which need a committer identity.
|
|
520
|
+
await g(['config', 'user.email', 'claude@anthropic.com']);
|
|
521
|
+
await g(['config', 'user.name', 'Claude']);
|
|
522
|
+
const branch = await this._currentBranch();
|
|
523
|
+
const upstream = `origin/${branch}`;
|
|
524
|
+
const count = async range => Number((await g(['rev-list', '--count', range])).stdout.trim());
|
|
525
|
+
const ahead = await count(`${upstream}..HEAD`);
|
|
526
|
+
const behind = await count(`HEAD..${upstream}`);
|
|
527
|
+
const head = await this._head();
|
|
528
|
+
const dirty = (await g(['status', '--porcelain=v1', '--untracked-files=no'])).stdout.split('\n').filter(Boolean).map(l => l.slice(3));
|
|
529
|
+
|
|
530
|
+
if (strategy === 'reset') {
|
|
531
|
+
if (confirm !== head) {
|
|
532
|
+
throw new Error(`reset discards local work; pass confirm: "${head}" (the current local HEAD) after reviewing the sync_project report.`);
|
|
533
|
+
}
|
|
534
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
535
|
+
const backups = [`mcp-backup/${stamp}`];
|
|
536
|
+
await g(['tag', backups[0], head]);
|
|
537
|
+
if (dirty.length) {
|
|
538
|
+
// stash create snapshots uncommitted tracked edits as a commit without
|
|
539
|
+
// touching the working tree; the tag keeps it reachable.
|
|
540
|
+
const wip = (await g(['stash', 'create', `mcp-backup ${stamp}`])).stdout.trim();
|
|
541
|
+
if (wip) { backups.push(`mcp-backup/${stamp}-wip`); await g(['tag', backups[1], wip]); }
|
|
542
|
+
}
|
|
543
|
+
await g(['reset', '--hard', upstream]);
|
|
544
|
+
return { state: 'reset', discardedCommits: ahead, discardedFiles: dirty, backups, ...(await this.pendingState()) };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (behind === 0) return { state: ahead ? 'ahead' : 'up-to-date', ahead, behind, head, dirtyFiles: dirty };
|
|
548
|
+
if (ahead === 0) {
|
|
549
|
+
await g(['merge', '--ff-only', upstream]);
|
|
550
|
+
return { state: 'fast-forwarded', ahead, behind, ...(await this.pendingState()) };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const log = async range => (await g(['log', '--format=%h %s', '--name-only', range])).stdout.trim();
|
|
554
|
+
const report = {
|
|
555
|
+
state: 'diverged', ahead, behind, head, upstream: (await g(['rev-parse', upstream])).stdout.trim(),
|
|
556
|
+
localCommits: await log(`${upstream}..HEAD`), remoteCommits: await log(`HEAD..${upstream}`), dirtyFiles: dirty,
|
|
557
|
+
options: 'strategy:"rebase" replays local commits onto Overleaf (aborts cleanly on conflict); strategy:"reset" with confirm:<head> discards local work after tagging a backup.',
|
|
558
|
+
};
|
|
559
|
+
if (strategy !== 'rebase') return report;
|
|
560
|
+
if (dirty.length) throw new Error(`rebase needs a clean working tree; uncommitted edits in: ${dirty.join(', ')}. Commit them or choose reset.`);
|
|
561
|
+
try {
|
|
562
|
+
await g(['rebase', upstream]);
|
|
563
|
+
} catch {
|
|
564
|
+
const conflicts = (await g(['diff', '--name-only', '--diff-filter=U']).catch(() => ({ stdout: '' }))).stdout.trim().split('\n').filter(Boolean);
|
|
565
|
+
await g(['rebase', '--abort']).catch(() => {});
|
|
566
|
+
return { ...report, state: 'rebase-conflict', conflicts, note: 'Rebase aborted; local state is unchanged.' };
|
|
567
|
+
}
|
|
568
|
+
return { state: 'rebased', ...(await this.pendingState()) };
|
|
409
569
|
}
|
|
410
570
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
571
|
+
// options.lint (true = every .tex file, or an array of paths) adds the voice
|
|
572
|
+
// linter to the gate: findings fail it like an undefined reference does.
|
|
573
|
+
// Lint runs outside the build cache, so a reused build verdict is never
|
|
574
|
+
// mutated and lint always sees the current files.
|
|
575
|
+
async verifyBuild(filePath, engine = 'lualatex', options = {}) {
|
|
576
|
+
const key = path.resolve(this.repoPath);
|
|
577
|
+
const previous = buildQueues.get(key) || Promise.resolve();
|
|
578
|
+
const task = previous.catch(() => {}).then(() => this._verifyLocal(filePath, engine, options));
|
|
579
|
+
buildQueues.set(key, task);
|
|
580
|
+
let verdict;
|
|
581
|
+
try { verdict = await task; } finally { if (buildQueues.get(key) === task) buildQueues.delete(key); }
|
|
582
|
+
if (!options.lint) return verdict;
|
|
583
|
+
const files = Array.isArray(options.lint) ? options.lint : await this.listFiles('.tex');
|
|
584
|
+
const lint = await this.lintFiles(files, options.lintCommand);
|
|
585
|
+
return { ...verdict, lint, pass: verdict.pass && lint.clean };
|
|
417
586
|
}
|
|
418
587
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
588
|
+
async lintFiles(files, command) {
|
|
589
|
+
const results = [];
|
|
590
|
+
for (const file of files) results.push({ file, ...(await this.voiceLint(file, { command })) });
|
|
591
|
+
return { clean: results.every(r => r.clean), results };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async _verifyLocal(filePath, engine, options = {}) {
|
|
595
|
+
const { force = false, clean = true } = options;
|
|
596
|
+
const config = controlledBuildOptions(options);
|
|
597
|
+
const key = JSON.stringify([this.repoPath,filePath,engine,config]);
|
|
598
|
+
const fingerprint = () => buildFingerprint(this.repoPath,filePath,engine,config).catch(()=>null);
|
|
599
|
+
const sources = () => buildFingerprint(this.repoPath,filePath,engine,{...config,sourcesOnly:true});
|
|
600
|
+
const cached = verifiedBuilds.get(key);
|
|
601
|
+
const before = await sources();
|
|
602
|
+
if (!force && cached && cached.fingerprint === await fingerprint()) return { ...cached.verdict, reused: true };
|
|
603
|
+
verifiedBuilds.delete(key);
|
|
604
|
+
const { log: runLog, pdfPath, commandFailed } = await this._runLatexmk(filePath,engine,{clean,controlled:config.controlled});
|
|
605
|
+
const logPath = path.join(this.repoPath,filePath.replace(/\.tex$/,'.log'));
|
|
429
606
|
let finalLog = runLog;
|
|
430
|
-
try { finalLog = await readFile(
|
|
607
|
+
try { finalLog = await readFile(logPath,'utf8'); } catch { /* failed before log creation */ }
|
|
431
608
|
const verdict = classifyBuildLog(finalLog);
|
|
432
|
-
|
|
433
|
-
// can't yield a false PASS; then recompute the verdict.
|
|
609
|
+
if (commandFailed) verdict.errors.push('latexmk command failed; any previous PDF is not a successful build.');
|
|
434
610
|
verdict.pdfProduced = pdfPath !== null;
|
|
435
|
-
verdict.pass = verdict.pdfProduced
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
verdict.
|
|
611
|
+
verdict.pass = verdict.pdfProduced && !verdict.errors.length && !verdict.undefinedRefs.length && !verdict.undefinedCitations.length;
|
|
612
|
+
verdict.logPath = logPath;
|
|
613
|
+
verdict.pdfPath = pdfPath;
|
|
614
|
+
verdict.tail = (commandFailed ? runLog : finalLog).slice(-2500);
|
|
615
|
+
verdict.reused = false;
|
|
616
|
+
const after = await sources();
|
|
617
|
+
if (before && before !== after) { verdict.pass = false; verdict.errors.push('Project inputs changed during the build; verify the latest source again.'); }
|
|
618
|
+
const print = before && before === after ? await fingerprint() : null;
|
|
619
|
+
verdict.cacheEligible = Boolean(print);
|
|
620
|
+
if (verdict.pass && print) verifiedBuilds.set(key,{ fingerprint:print,verdict });
|
|
440
621
|
return verdict;
|
|
441
622
|
}
|
|
442
623
|
|
|
443
624
|
// Read-only grep across tracked files. Regex by default; fixed -> -F; ignoreCase -> -i.
|
|
444
625
|
async searchText({ query, fixed = false, ignoreCase = false, extension } = {}) {
|
|
445
626
|
if (!query) throw new Error('search_text needs a query.');
|
|
446
|
-
await this.
|
|
627
|
+
await this.requireLocal();
|
|
447
628
|
const args = ['-C', this.repoPath, 'grep', '-n', '--no-color', fixed ? '-F' : '-E'];
|
|
448
629
|
if (ignoreCase) args.push('-i');
|
|
449
630
|
args.push('-e', query);
|
|
@@ -458,34 +639,28 @@ class OverleafGitClient {
|
|
|
458
639
|
}
|
|
459
640
|
}
|
|
460
641
|
|
|
461
|
-
// Append a BibTeX entry to refs.bib (reject a duplicate key), commit,
|
|
462
|
-
|
|
642
|
+
// Append a BibTeX entry to refs.bib (reject a duplicate key), commit, and
|
|
643
|
+
// push when push is true.
|
|
644
|
+
async addCitation({ entry, commitMessage, push = true } = {}) {
|
|
463
645
|
if (!entry || !entry.trim()) throw new Error('add_citation needs a BibTeX entry.');
|
|
464
646
|
const m = entry.match(/@\w+\s*\{\s*([^,\s]+)/);
|
|
465
647
|
if (!m) throw new Error('Could not find a BibTeX key in the entry (expected @type{key, ...}).');
|
|
466
648
|
const key = m[1];
|
|
467
|
-
await this.
|
|
649
|
+
const preHead = await this._prepareMutation(push);
|
|
468
650
|
const bibPath = path.join(this.repoPath, 'refs.bib');
|
|
469
651
|
let current = '';
|
|
470
652
|
try { current = await readFile(bibPath, 'utf-8'); } catch { /* missing -> create */ }
|
|
471
653
|
const dup = new RegExp(`@\\w+\\s*\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*,`);
|
|
472
654
|
if (dup.test(current)) throw new Error(`citation key "${key}" already in refs.bib.`);
|
|
473
655
|
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
656
|
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 };
|
|
657
|
+
if (!(await this._commit(commitMessage || `Add citation ${key}`))) return { pushed: false, reason: 'nothing to commit', key };
|
|
658
|
+
return { ...(await this._finishMutation(preHead, push, { merge: true })), key };
|
|
484
659
|
}
|
|
485
660
|
|
|
486
661
|
// Read-only: cited keys (across .tex) vs defined keys (refs.bib).
|
|
487
662
|
async citeLint() {
|
|
488
|
-
await this.
|
|
663
|
+
await this.requireLocal();
|
|
489
664
|
const texFiles = await this.listFiles('.tex');
|
|
490
665
|
const citeRe = /\\(?:cite|autocite|parencite|citep|citet|textcite|footcite|nocite)\*?(?:\[[^\]]*\])*\{([^}]+)\}/g;
|
|
491
666
|
const cited = new Set();
|
|
@@ -519,7 +694,7 @@ class OverleafGitClient {
|
|
|
519
694
|
|
|
520
695
|
// Local rollback point: a lightweight tag mcp-snap/<label> at HEAD (not pushed).
|
|
521
696
|
async checkpoint(label) {
|
|
522
|
-
await this.
|
|
697
|
+
await this.requireLocal();
|
|
523
698
|
const name = `mcp-snap/${(label && label.trim()) || `snap-${Date.now()}`}`;
|
|
524
699
|
let exists = false;
|
|
525
700
|
try { await this._git(['-C', this.repoPath, 'rev-parse', '--verify', '--quiet', `refs/tags/${name}`]); exists = true; } catch { exists = false; }
|
|
@@ -529,10 +704,12 @@ class OverleafGitClient {
|
|
|
529
704
|
return { label: name, head: stdout.trim() };
|
|
530
705
|
}
|
|
531
706
|
|
|
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
|
-
|
|
707
|
+
// Forward-restore the snapshot's tree as a new commit on top of HEAD, pushed
|
|
708
|
+
// when push is true. No history rewrite, no force-push (the new commit
|
|
709
|
+
// descends from HEAD). The fast-forward refuses rather than overwriting
|
|
710
|
+
// uncommitted local edits to files the restore would change.
|
|
711
|
+
async restore(label, { push = true } = {}) {
|
|
712
|
+
const preHead = await this._prepareMutation(push);
|
|
536
713
|
const name = String(label || '').startsWith('mcp-snap/') ? label : `mcp-snap/${label}`;
|
|
537
714
|
let tree;
|
|
538
715
|
try { ({ stdout: tree } = await this._git(['-C', this.repoPath, 'rev-parse', `${name}^{tree}`])); }
|
|
@@ -541,19 +718,10 @@ class OverleafGitClient {
|
|
|
541
718
|
throw new Error(`snapshot "${name}" not found. Available: ${tags || '(none)'}`);
|
|
542
719
|
}
|
|
543
720
|
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
721
|
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 };
|
|
722
|
+
await this._git(['-C', this.repoPath, 'merge', '--ff-only', commit.trim()]);
|
|
723
|
+
const res = await this._finishMutation(preHead, push, { refusal: 'restore: Overleaf moved during the rollback; refused. Run sync_project, then retry.' });
|
|
724
|
+
return { ...res, label: name, restoredTo: tree };
|
|
557
725
|
}
|
|
558
726
|
|
|
559
727
|
// Run the configured voice linter on a file; advisory, read-only.
|
|
@@ -605,9 +773,11 @@ class OverleafGitClient {
|
|
|
605
773
|
}
|
|
606
774
|
|
|
607
775
|
// 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
|
-
|
|
776
|
+
// let git 3-way merge it: clean merge -> push; real conflict -> abort, roll
|
|
777
|
+
// back to preHead (the state before this operation's commit), and throw, so
|
|
778
|
+
// nothing half-applied is left behind and earlier local commits survive.
|
|
779
|
+
async _pushWithMerge(preHead) {
|
|
780
|
+
if (!preHead) throw new Error('_pushWithMerge needs the pre-operation HEAD to roll back to.');
|
|
611
781
|
try {
|
|
612
782
|
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
613
783
|
return { pushed: true, merged: false };
|
|
@@ -618,15 +788,15 @@ class OverleafGitClient {
|
|
|
618
788
|
await this._git(['-C', this.repoPath, 'merge', '--no-edit', `origin/${branch}`]);
|
|
619
789
|
} catch (mergeErr) {
|
|
620
790
|
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.
|
|
791
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]);
|
|
792
|
+
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
793
|
e.cause = mergeErr;
|
|
624
794
|
throw e;
|
|
625
795
|
}
|
|
626
796
|
try {
|
|
627
797
|
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
628
798
|
} catch (e2) {
|
|
629
|
-
await this._git(['-C', this.repoPath, 'reset', '--hard',
|
|
799
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]).catch(() => {});
|
|
630
800
|
const e = new Error('conflict: Overleaf moved again while merging; reset clean — re-read the file and retry.');
|
|
631
801
|
e.cause = e2;
|
|
632
802
|
throw e;
|
|
@@ -639,10 +809,10 @@ class OverleafGitClient {
|
|
|
639
809
|
// files: require either a matching baseSha (proves freshness) or overwrite:true
|
|
640
810
|
// (a deliberate clobber). A stale baseSha is refused, never merged.
|
|
641
811
|
async writeFile(filePath, content, opts = {}) {
|
|
642
|
-
const { baseSha, overwrite = false, commitMessage } = opts;
|
|
643
|
-
await this.
|
|
812
|
+
const { baseSha, overwrite = false, commitMessage, push = true } = opts;
|
|
813
|
+
const preHead = await this._prepareMutation(push);
|
|
644
814
|
const fullPath = path.join(this.repoPath, filePath);
|
|
645
|
-
const current = await this.getBlobSha(filePath
|
|
815
|
+
const current = await this.getBlobSha(filePath); // null if new
|
|
646
816
|
|
|
647
817
|
if (current !== null) {
|
|
648
818
|
if (baseSha != null) {
|
|
@@ -656,28 +826,10 @@ class OverleafGitClient {
|
|
|
656
826
|
|
|
657
827
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
658
828
|
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
829
|
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
|
-
}
|
|
830
|
+
if (!(await this._commit(commitMessage || `Update ${filePath} via Claude`))) return { pushed: false, reason: 'nothing to commit' };
|
|
670
831
|
// 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 };
|
|
832
|
+
return this._finishMutation(preHead, push, { refusal: `${filePath}: Overleaf moved while writing; refused to overwrite. Run sync_project, re-read and retry.` });
|
|
681
833
|
}
|
|
682
834
|
|
|
683
835
|
// Upload binary file(s) from local disk into the clone and push. Single mode:
|
|
@@ -686,7 +838,7 @@ class OverleafGitClient {
|
|
|
686
838
|
// Binary never 3-way-merges, so a push race refuses + resets like writeFile.
|
|
687
839
|
// baseSha freshness applies in single mode only; existing files in batch mode
|
|
688
840
|
// require overwrite:true.
|
|
689
|
-
async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage } = {}) {
|
|
841
|
+
async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage, push = true } = {}) {
|
|
690
842
|
let pairs;
|
|
691
843
|
const batch = Array.isArray(files);
|
|
692
844
|
if (batch) {
|
|
@@ -699,7 +851,7 @@ class OverleafGitClient {
|
|
|
699
851
|
throw new Error('upload_file needs srcPath+destPath (single) or files:[{srcPath,destPath}] (batch).');
|
|
700
852
|
}
|
|
701
853
|
|
|
702
|
-
await this.
|
|
854
|
+
const preHead = await this._prepareMutation(push);
|
|
703
855
|
const repoAbs = path.resolve(this.repoPath);
|
|
704
856
|
const resolved = [];
|
|
705
857
|
for (const { src, dest } of pairs) {
|
|
@@ -711,7 +863,7 @@ class OverleafGitClient {
|
|
|
711
863
|
if (destAbs === repoAbs || rel.startsWith('..') || path.isAbsolute(rel) || rel.split(path.sep)[0] === '.git') {
|
|
712
864
|
throw new Error(`destPath escapes the project or is not allowed: ${dest}`);
|
|
713
865
|
}
|
|
714
|
-
const current = await this.getBlobSha(rel
|
|
866
|
+
const current = await this.getBlobSha(rel);
|
|
715
867
|
if (current !== null) {
|
|
716
868
|
if (!batch && baseSha != null) {
|
|
717
869
|
if (baseSha !== current) {
|
|
@@ -729,34 +881,21 @@ class OverleafGitClient {
|
|
|
729
881
|
await copyFile(src, destAbs);
|
|
730
882
|
}
|
|
731
883
|
|
|
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;
|
|
884
|
+
const rels = resolved.map(r => r.rel);
|
|
885
|
+
await this._git(['-C', this.repoPath, 'add', '--', ...rels]);
|
|
886
|
+
if (!(await this._commit(commitMessage || `Upload ${resolved.length} file(s) via Claude`))) {
|
|
887
|
+
return { pushed: false, reason: 'nothing to commit (identical to repo)', files: rels };
|
|
742
888
|
}
|
|
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) };
|
|
889
|
+
const res = await this._finishMutation(preHead, push, { refusal: 'Overleaf moved while uploading; refused. Run sync_project and retry.' });
|
|
890
|
+
return { ...res, files: rels };
|
|
753
891
|
}
|
|
754
892
|
|
|
755
|
-
// Anchored, conflict-safe edit.
|
|
756
|
-
// edits)
|
|
757
|
-
// region (overlap) or the string was
|
|
758
|
-
|
|
759
|
-
|
|
893
|
+
// Anchored, conflict-safe edit. When pushing, pulls first (absorbing
|
|
894
|
+
// non-overlapping Overleaf edits); local-only edits never touch the network.
|
|
895
|
+
// A missing anchor means the region changed (overlap) or the string was
|
|
896
|
+
// wrong -> refuse, nothing written.
|
|
897
|
+
async editFile(filePath, oldString, newString, replaceAll = false, commitMessage, { push = true } = {}) {
|
|
898
|
+
const preHead = await this._prepareMutation(push);
|
|
760
899
|
const fullPath = path.join(this.repoPath, filePath);
|
|
761
900
|
let content;
|
|
762
901
|
try { content = await readFile(fullPath, 'utf-8'); }
|
|
@@ -772,39 +911,39 @@ class OverleafGitClient {
|
|
|
772
911
|
}
|
|
773
912
|
const updated = replaceAll ? parts.join(newString) : content.replace(oldString, () => newString);
|
|
774
913
|
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
914
|
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();
|
|
915
|
+
if (!(await this._commit(commitMessage || `Edit ${filePath} via Claude`))) return { pushed: false, reason: 'no change (new === old)' };
|
|
916
|
+
return this._finishMutation(preHead, push, { merge: true });
|
|
788
917
|
}
|
|
789
918
|
|
|
919
|
+
// Same slicing as the bundle (sectionText): runs to the next heading of the
|
|
920
|
+
// same or higher level, covers \paragraph, and refuses an ambiguous title
|
|
921
|
+
// rather than silently returning the first match.
|
|
790
922
|
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);
|
|
923
|
+
return sectionText(await this.readFile(filePath), sectionTitle);
|
|
803
924
|
}
|
|
804
925
|
}
|
|
805
926
|
|
|
806
927
|
export { OverleafGitClient };
|
|
807
928
|
|
|
929
|
+
// settings.voiceLinter / $OVERLEAF_VOICE_LINTER override the bundled example
|
|
930
|
+
// linter, which ships with the package so voice_lint works out of the box. The
|
|
931
|
+
// example implements generic prose checks; point the setting at your own
|
|
932
|
+
// command to enforce a house style.
|
|
933
|
+
function voiceLinterCommand(config) {
|
|
934
|
+
return config.settings?.voiceLinter
|
|
935
|
+
|| process.env.OVERLEAF_VOICE_LINTER
|
|
936
|
+
|| `node ${path.join(PACKAGE_DIR, 'examples', 'voice-lint.mjs')}`;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// What a mutating tool reports: pushed, or committed locally with the count of
|
|
940
|
+
// commits still waiting for publish_changes.
|
|
941
|
+
function mutationTail(res, what) {
|
|
942
|
+
if (res.pushed) return `${what} and pushed to Overleaf${res.merged ? ' (auto-merged a concurrent Overleaf change)' : ''}.`;
|
|
943
|
+
const n = res.unpublished == null ? 'unknown' : res.unpublished;
|
|
944
|
+
return `${what} and committed locally (HEAD ${res.head.slice(0, 12)}; ${n} unpublished commit(s)). NEXT: finish the batch, verify_build, then publish_changes with revision ${res.head} once publishing is authorized.`;
|
|
945
|
+
}
|
|
946
|
+
|
|
808
947
|
async function getClient(projectName) {
|
|
809
948
|
const config = await loadConfig();
|
|
810
949
|
const key = pickProjectKey(config, projectName);
|
|
@@ -973,12 +1112,21 @@ const server = new Server(
|
|
|
973
1112
|
|
|
974
1113
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
975
1114
|
tools: [
|
|
1115
|
+
...[
|
|
1116
|
+
['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' } } }, []],
|
|
1117
|
+
['change_report', 'Compact local file and section changes against an in-process baseline. Omit baselineVersion to establish one.', { baselineVersion: { type: 'string' } }, []],
|
|
1118
|
+
['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']],
|
|
1119
|
+
['usage_stats', 'In-process tool counts, durations, response bytes and cache hits. Stores no document content. Bytes are not billed tokens.', { reset: { type: 'boolean' } }, []],
|
|
1120
|
+
['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']],
|
|
1121
|
+
['publish_changes', 'Verify the clean local HEAD and push it once, publishing every unpublished local commit (from apply_changes or local-mode edit tools) together. revision must equal HEAD. No pull, retry, merge or reset; if Overleaf moved, run sync_project first. Requires publishing authorization.', { revision: { type: 'string' }, 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']],
|
|
1122
|
+
].map(([name, description, properties, required]) => ({ name, description, inputSchema: { type: 'object', properties: { projectName: { type: 'string' }, ...properties }, required } })),
|
|
976
1123
|
{
|
|
977
1124
|
name: 'get_context',
|
|
978
|
-
description: 'Read writing
|
|
1125
|
+
description: 'Read current writing and project context. Supply previousVersion to receive a compact unchanged response when content and project identity match.',
|
|
979
1126
|
inputSchema: {
|
|
980
1127
|
type: 'object',
|
|
981
1128
|
properties: {
|
|
1129
|
+
previousVersion: { type: 'string', description: 'Version returned by the previous context read.' },
|
|
982
1130
|
projectName: { type: 'string', description: 'Project key. Omit to auto-detect from current working directory.' },
|
|
983
1131
|
},
|
|
984
1132
|
},
|
|
@@ -1019,7 +1167,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1019
1167
|
},
|
|
1020
1168
|
{
|
|
1021
1169
|
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.',
|
|
1170
|
+
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
1171
|
inputSchema: {
|
|
1024
1172
|
type: 'object',
|
|
1025
1173
|
properties: {
|
|
@@ -1028,6 +1176,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1028
1176
|
ssaSubdir: { type: 'string', description: 'Subfolder name created under each course folder for new SSAs, e.g. "MY SSAs".' },
|
|
1029
1177
|
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
1178
|
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.' },
|
|
1179
|
+
autoPush: { type: 'boolean', description: 'true: edit tools push immediately. false (default): they commit locally and publish_changes sends the verified batch.' },
|
|
1031
1180
|
gitToken: { type: 'string', description: 'Overleaf git token. Prefer the OVERLEAF_GIT_TOKEN env var; set here only to store it in projects.json.' },
|
|
1032
1181
|
},
|
|
1033
1182
|
},
|
|
@@ -1107,38 +1256,42 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1107
1256
|
required: ['filePath'],
|
|
1108
1257
|
},
|
|
1109
1258
|
},
|
|
1259
|
+
{
|
|
1260
|
+
name: 'sync_project',
|
|
1261
|
+
description: 'Fetch Overleaf and reconcile the local clone. Fast-forwards when only behind; reports unpublished local commits when ahead. On divergence it changes nothing and returns both sides, unless strategy is given: "rebase" replays local commits onto Overleaf (aborts cleanly on conflict); "reset" discards local work to match Overleaf, requires confirm set to the reported local head, and tags mcp-backup/* first. Clones when no local copy exists. Builds and reads never sync.',
|
|
1262
|
+
inputSchema: { type: 'object', properties: {
|
|
1263
|
+
strategy: { type: 'string', enum: ['rebase', 'reset'], description: 'Only for a diverged clone. Omit to get the report first.' },
|
|
1264
|
+
confirm: { type: 'string', description: 'For strategy "reset": the full local head SHA from the report.' },
|
|
1265
|
+
projectName: { type: 'string' },
|
|
1266
|
+
} },
|
|
1267
|
+
},
|
|
1110
1268
|
{
|
|
1111
1269
|
name: 'get_section_content',
|
|
1112
|
-
description: '
|
|
1270
|
+
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
1271
|
inputSchema: {
|
|
1114
1272
|
type: 'object',
|
|
1115
1273
|
properties: {
|
|
1116
1274
|
filePath: { type: 'string' },
|
|
1117
1275
|
sectionTitle: { type: 'string' },
|
|
1276
|
+
bundle: { type: 'boolean', default: false, description: 'Include referenced blocks, bibliography entries and assets.' },
|
|
1277
|
+
maxChars: { type: 'integer', minimum: 2000, maximum: 64000, description: 'bundle only: response budget (default 16000).' },
|
|
1118
1278
|
projectName: { type: 'string' },
|
|
1119
1279
|
},
|
|
1120
1280
|
required: ['filePath', 'sectionTitle'],
|
|
1121
1281
|
},
|
|
1122
1282
|
},
|
|
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
1283
|
{
|
|
1137
1284
|
name: 'verify_build',
|
|
1138
|
-
description: '
|
|
1285
|
+
description: 'Build the local entrypoint and return a PASS/FAIL verdict on the done-bar: PASS only if a PDF is produced with zero LaTeX errors, zero undefined references and zero undefined citations (and, with lint, zero voice-linter findings). Reports page count; overfull/underfull boxes are warnings. Default is the final gate: a clean from-scratch build, reusing an unchanged eligible PASS unless force is true. clean:false is a quick incremental rebuild for intermediate layout checks. Local only; no pull.',
|
|
1139
1286
|
inputSchema: {
|
|
1140
1287
|
type: 'object',
|
|
1141
1288
|
properties: {
|
|
1289
|
+
clean: { type: 'boolean', default: true, description: 'false = quick incremental rebuild that always recompiles (intermediate checks); true = from-scratch final gate.' },
|
|
1290
|
+
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' } }] },
|
|
1291
|
+
controlled: { type: 'boolean', default: false, description: 'Ignore all latexmk rc files and disable shell escape. Opt in only when the project supports this mode.' },
|
|
1292
|
+
externalInputs: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of additional build inputs to hash.' },
|
|
1293
|
+
verbose: { type: 'boolean', default: false, description: 'Include a bounded log tail; full log remains on disk.' },
|
|
1294
|
+
force: { type: 'boolean', default: false, description: 'Ignore a cached verification and rebuild.' },
|
|
1142
1295
|
filePath: { type: 'string', description: 'The entrypoint, usually main.tex.' },
|
|
1143
1296
|
engine: { type: 'string', description: 'pdflatex | xelatex | lualatex (default lualatex).' },
|
|
1144
1297
|
projectName: { type: 'string' },
|
|
@@ -1148,7 +1301,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1148
1301
|
},
|
|
1149
1302
|
{
|
|
1150
1303
|
name: 'edit_file',
|
|
1151
|
-
description: 'Surgical, conflict-safe edit: replace oldString with newString in a file
|
|
1304
|
+
description: 'Surgical, conflict-safe edit: replace oldString with newString in a file and commit. Commits locally by default (push:false unless settings.autoPush); publish_changes sends the verified batch. PREFER this over write_file for edits to existing files — it is far cheaper than a full rewrite and it cannot silently clobber a concurrent Overleaf edit (a missing oldString means the region changed; the edit refuses). When pushing, non-overlapping concurrent Overleaf edits auto-merge. oldString must match exactly once unless replaceAll is true. After the edit batch, use verify_build as the single final gate.',
|
|
1152
1305
|
inputSchema: {
|
|
1153
1306
|
type: 'object',
|
|
1154
1307
|
properties: {
|
|
@@ -1157,6 +1310,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1157
1310
|
newString: { type: 'string', description: 'Replacement text.' },
|
|
1158
1311
|
replaceAll: { type: 'boolean', description: 'Replace every occurrence (default false; otherwise oldString must be unique).' },
|
|
1159
1312
|
commitMessage: { type: 'string' },
|
|
1313
|
+
push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' },
|
|
1160
1314
|
projectName: { type: 'string' },
|
|
1161
1315
|
},
|
|
1162
1316
|
required: ['filePath', 'oldString', 'newString'],
|
|
@@ -1164,7 +1318,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1164
1318
|
},
|
|
1165
1319
|
{
|
|
1166
1320
|
name: 'write_file',
|
|
1167
|
-
description: 'Create a new file, or overwrite an existing one wholesale,
|
|
1321
|
+
description: 'Create a new file, or overwrite an existing one wholesale, and commit (local by default; see push). For edits to existing files prefer edit_file. Overwriting an existing file requires either baseSha (from read_file, so a stale write is refused) or overwrite:true. After the edit batch, use verify_build as the single final gate.',
|
|
1168
1322
|
inputSchema: {
|
|
1169
1323
|
type: 'object',
|
|
1170
1324
|
properties: {
|
|
@@ -1173,6 +1327,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1173
1327
|
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
1328
|
overwrite: { type: 'boolean', description: 'Force-overwrite an existing file without a baseSha (deliberate full replacement). Ignored for new files.' },
|
|
1175
1329
|
commitMessage: { type: 'string' },
|
|
1330
|
+
push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' },
|
|
1176
1331
|
projectName: { type: 'string' },
|
|
1177
1332
|
},
|
|
1178
1333
|
required: ['filePath', 'content'],
|
|
@@ -1180,7 +1335,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1180
1335
|
},
|
|
1181
1336
|
{
|
|
1182
1337
|
name: 'upload_file',
|
|
1183
|
-
description: 'Upload a binary file (PNG/PDF figure, etc.) from a local disk path INTO the Overleaf project and push. write_file/edit_file are UTF-8 only — use this for binaries. Single: srcPath + destPath. Batch (one commit for a figure set): files: [{srcPath, destPath}, ...]. Existing dest files need baseSha (single mode, from read_file) or overwrite:true. After uploading, reference each figure with \\includegraphics{...} via edit_file, then
|
|
1338
|
+
description: 'Upload a binary file (PNG/PDF figure, etc.) from a local disk path INTO the Overleaf project and commit (local by default; see push). write_file/edit_file are UTF-8 only — use this for binaries. Single: srcPath + destPath. Batch (one commit for a figure set): files: [{srcPath, destPath}, ...]. Existing dest files need baseSha (single mode, from read_file) or overwrite:true. After uploading, reference each figure with \\includegraphics{...} via edit_file, then verify_build.',
|
|
1184
1339
|
inputSchema: {
|
|
1185
1340
|
type: 'object',
|
|
1186
1341
|
properties: {
|
|
@@ -1194,6 +1349,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1194
1349
|
baseSha: { type: 'string', description: 'Single-file mode only: baseSha from read_file; a stale value is refused. Ignored in batch.' },
|
|
1195
1350
|
overwrite: { type: 'boolean', description: 'Replace existing dest file(s). Required to overwrite in batch mode.' },
|
|
1196
1351
|
commitMessage: { type: 'string' },
|
|
1352
|
+
push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' },
|
|
1197
1353
|
projectName: { type: 'string' },
|
|
1198
1354
|
},
|
|
1199
1355
|
},
|
|
@@ -1215,12 +1371,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1215
1371
|
},
|
|
1216
1372
|
{
|
|
1217
1373
|
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.',
|
|
1374
|
+
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
1375
|
inputSchema: {
|
|
1220
1376
|
type: 'object',
|
|
1221
1377
|
properties: {
|
|
1222
1378
|
entry: { type: 'string', description: 'A complete BibTeX entry, e.g. @article{key, title={...}, ...}.' },
|
|
1223
1379
|
commitMessage: { type: 'string' },
|
|
1380
|
+
push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' },
|
|
1224
1381
|
projectName: { type: 'string' },
|
|
1225
1382
|
},
|
|
1226
1383
|
required: ['entry'],
|
|
@@ -1238,12 +1395,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1238
1395
|
},
|
|
1239
1396
|
{
|
|
1240
1397
|
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'] },
|
|
1398
|
+
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.',
|
|
1399
|
+
inputSchema: { type: 'object', properties: { label: { type: 'string' }, push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' }, projectName: { type: 'string' } }, required: ['label'] },
|
|
1243
1400
|
},
|
|
1244
1401
|
{
|
|
1245
1402
|
name: 'voice_lint',
|
|
1246
|
-
description: 'Lint a .tex file for prose issues. Runs a bundled generic example linter by default; override with settings.voiceLinter in projects.json or the OVERLEAF_VOICE_LINTER env var (a command that takes a file path and exits non-zero on findings). Lints the LOCAL working copy as-is and never pulls, so it reflects on-disk state including edits not yet pushed; if the project has not been cloned locally yet it errors rather than fetching. Read-only and advisory
|
|
1403
|
+
description: 'Lint a .tex file for prose issues. Runs a bundled generic example linter by default; override with settings.voiceLinter in projects.json or the OVERLEAF_VOICE_LINTER env var (a command that takes a file path and exits non-zero on findings). Lints the LOCAL working copy as-is and never pulls, so it reflects on-disk state including edits not yet pushed; if the project has not been cloned locally yet it errors rather than fetching. Read-only and advisory on its own; verify_build with lint makes findings fail the final gate. Useful after editing prose via edit_file/write_file, which bypass any local editor hooks.',
|
|
1247
1404
|
inputSchema: {
|
|
1248
1405
|
type: 'object',
|
|
1249
1406
|
properties: { filePath: { type: 'string' }, projectName: { type: 'string' } },
|
|
@@ -1261,7 +1418,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1261
1418
|
],
|
|
1262
1419
|
}));
|
|
1263
1420
|
|
|
1264
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1421
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => observeTool(request.params.name, async () => {
|
|
1265
1422
|
try {
|
|
1266
1423
|
const { name, arguments: args } = request.params;
|
|
1267
1424
|
|
|
@@ -1501,7 +1658,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1501
1658
|
const key = pickProjectKey(config, args.projectName);
|
|
1502
1659
|
const project = config.projects[key];
|
|
1503
1660
|
let guidelines = '';
|
|
1504
|
-
|
|
1661
|
+
const guidelinesPath = resolveGuidelinesPath({ dataHome: DATA_HOME, packageDir: PACKAGE_DIR, exists: existsSync });
|
|
1662
|
+
try { guidelines = await readFile(guidelinesPath, 'utf-8'); }
|
|
1505
1663
|
catch { guidelines = '(writing-guidelines.md missing from OverleafMCP folder)'; }
|
|
1506
1664
|
const ctx = await readContext(key, project);
|
|
1507
1665
|
const text = [
|
|
@@ -1522,7 +1680,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1522
1680
|
``,
|
|
1523
1681
|
ctx.body,
|
|
1524
1682
|
].join('\n');
|
|
1525
|
-
|
|
1683
|
+
const v = versionedContext(key,text,args.previousVersion);
|
|
1684
|
+
return { content: [{ type: 'text', text: `Context version: ${v.version}\n${v.text}` }], structuredContent: { version:v.version, unchanged:v.unchanged, projectName:key } };
|
|
1526
1685
|
}
|
|
1527
1686
|
|
|
1528
1687
|
case 'list_files': {
|
|
@@ -1536,7 +1695,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1536
1695
|
const content = await client.readFile(args.filePath);
|
|
1537
1696
|
const baseSha = await client.getBlobSha(args.filePath, { pull: false });
|
|
1538
1697
|
const header = `<!-- overleaf-mcp baseSha: ${baseSha || 'none'} (pass as baseSha to write_file to guard against clobbering Overleaf edits) -->\n`;
|
|
1539
|
-
|
|
1698
|
+
const { stdout } = await client._git(['-C', client.repoPath, 'rev-parse', 'HEAD']);
|
|
1699
|
+
return { content: [{ type: 'text', text: header + content }], structuredContent: { baseSha, baseRevision: stdout.trim(), contentHash: createHash('sha256').update(content).digest('hex') } };
|
|
1540
1700
|
}
|
|
1541
1701
|
|
|
1542
1702
|
case 'get_sections': {
|
|
@@ -1547,65 +1707,109 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1547
1707
|
|
|
1548
1708
|
case 'get_section_content': {
|
|
1549
1709
|
const { client } = await getClient(args.projectName);
|
|
1710
|
+
if (args.bundle) {
|
|
1711
|
+
await client.requireLocal();
|
|
1712
|
+
const result = await sectionBundle(client.repoPath, args.filePath, args.sectionTitle, args.maxChars);
|
|
1713
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
1714
|
+
}
|
|
1550
1715
|
const content = await client.getSectionContent(args.filePath, args.sectionTitle);
|
|
1551
1716
|
return { content: [{ type: 'text', text: content }] };
|
|
1552
1717
|
}
|
|
1553
1718
|
|
|
1554
|
-
case '
|
|
1719
|
+
case 'sync_project': {
|
|
1555
1720
|
const { client } = await getClient(args.projectName);
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1721
|
+
const result = await client.syncProject({ strategy: args.strategy, confirm: args.confirm });
|
|
1722
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
|
|
1723
|
+
}
|
|
1724
|
+
case 'usage_stats': {
|
|
1725
|
+
const result = usageStats(args);
|
|
1726
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
1727
|
+
}
|
|
1728
|
+
case 'dependency_index':
|
|
1729
|
+
case 'change_report':
|
|
1730
|
+
case 'render_pages': {
|
|
1731
|
+
const { client } = await getClient(args.projectName);
|
|
1732
|
+
await client.requireLocal();
|
|
1733
|
+
const result = name === 'dependency_index' ? await dependencyIndex(client.repoPath, args)
|
|
1734
|
+
: name === 'change_report' ? await changeReport(client.repoPath, args.baselineVersion)
|
|
1735
|
+
: await renderPages(client.repoPath, args);
|
|
1736
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
1737
|
+
}
|
|
1738
|
+
case 'apply_changes':
|
|
1739
|
+
case 'publish_changes': {
|
|
1740
|
+
const config = await loadConfig();
|
|
1741
|
+
const { client } = await getClient(args.projectName);
|
|
1742
|
+
await client.requireLocal();
|
|
1743
|
+
const verify = async root => {
|
|
1744
|
+
const candidate = new OverleafGitClient(client.projectId, client.gitToken, root);
|
|
1745
|
+
return candidate.verifyBuild(args.filePath, args.engine || 'lualatex', {
|
|
1746
|
+
controlled: args.controlled === true,
|
|
1747
|
+
externalInputs: args.externalInputs,
|
|
1748
|
+
lint: args.lint,
|
|
1749
|
+
lintCommand: voiceLinterCommand(config),
|
|
1750
|
+
});
|
|
1751
|
+
};
|
|
1752
|
+
const result = name === 'apply_changes' ? await applyChanges(client.repoPath, args, verify)
|
|
1753
|
+
: await publishChanges(client.repoPath, args, verify, async (root, revision, branch) => {
|
|
1754
|
+
try {
|
|
1755
|
+
await client._git(['-C', root, 'push', 'origin', `${revision}:refs/heads/${branch}`], { auth: true });
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
// A rejected push means Overleaf moved since the last sync; name
|
|
1758
|
+
// the recovery instead of surfacing raw git plumbing.
|
|
1759
|
+
if (/rejected|fetch first|non-fast-forward/i.test(e.stderr || '')) {
|
|
1760
|
+
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.');
|
|
1761
|
+
}
|
|
1762
|
+
throw e;
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
1564
1766
|
}
|
|
1565
|
-
|
|
1566
1767
|
case 'verify_build': {
|
|
1768
|
+
const config = await loadConfig();
|
|
1567
1769
|
const { client } = await getClient(args.projectName);
|
|
1568
|
-
const
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
if (
|
|
1576
|
-
if (
|
|
1577
|
-
if (v.
|
|
1578
|
-
|
|
1579
|
-
if (v.overfullCount || v.underfullCount) parts.push(`- (warnings) ${v.overfullCount} overfull / ${v.underfullCount} underfull`);
|
|
1580
|
-
parts.push(`\n--- log tail ---\n${v.tail}`);
|
|
1581
|
-
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
1770
|
+
const opts = { ...args, lintCommand: voiceLinterCommand(config) };
|
|
1771
|
+
const v = args.clean === false
|
|
1772
|
+
? await client.compileFile(args.filePath, args.engine || 'lualatex', opts)
|
|
1773
|
+
: await client.verifyBuild(args.filePath, args.engine || 'lualatex', opts);
|
|
1774
|
+
const parts = [`${v.pass ? 'PASS' : 'FAIL'}: ${v.pageCount ?? '?'} pages; ${v.errors.length} errors; ${v.undefinedRefs.length} undefined references; ${v.undefinedCitations.length} undefined citations; ${v.overfullCount} overfull / ${v.underfullCount} underfull boxes.`,
|
|
1775
|
+
`Reused verification: ${v.reused}. Full log: ${v.logPath}`];
|
|
1776
|
+
if (!v.pass) parts.push(...v.errors.slice(0,5),...v.undefinedRefs.slice(0,5),...v.undefinedCitations.slice(0,5));
|
|
1777
|
+
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')}`);
|
|
1778
|
+
if (args.verbose) parts.push(v.tail);
|
|
1779
|
+
if (!v.cacheEligible && !v.reused) parts.push('Cache not retained: dependency closure unavailable, executable configuration, or changing inputs.');
|
|
1780
|
+
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
1781
|
}
|
|
1583
1782
|
|
|
1584
1783
|
case 'edit_file': {
|
|
1784
|
+
const config = await loadConfig();
|
|
1585
1785
|
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
|
-
: `
|
|
1786
|
+
const res = await client.editFile(args.filePath, args.oldString, args.newString, args.replaceAll || false, args.commitMessage, { push: resolvePush(config.settings, args) });
|
|
1787
|
+
const tail = res.reason
|
|
1788
|
+
? `No change applied to ${args.filePath} (${res.reason}).`
|
|
1789
|
+
: mutationTail(res, `Edited ${args.filePath}`);
|
|
1590
1790
|
return { content: [{ type: 'text', text: tail }] };
|
|
1591
1791
|
}
|
|
1592
1792
|
|
|
1593
1793
|
case 'write_file': {
|
|
1794
|
+
const config = await loadConfig();
|
|
1594
1795
|
const { client } = await getClient(args.projectName);
|
|
1595
1796
|
const res = await client.writeFile(args.filePath, args.content, {
|
|
1596
1797
|
baseSha: args.baseSha,
|
|
1597
1798
|
overwrite: args.overwrite,
|
|
1598
1799
|
commitMessage: args.commitMessage,
|
|
1800
|
+
push: resolvePush(config.settings, args),
|
|
1599
1801
|
});
|
|
1600
|
-
const tail = res.
|
|
1601
|
-
? `
|
|
1602
|
-
: `
|
|
1802
|
+
const tail = res.reason
|
|
1803
|
+
? `No change detected for ${args.filePath} (${res.reason}).`
|
|
1804
|
+
: mutationTail(res, `Wrote ${args.filePath}`);
|
|
1603
1805
|
return { content: [{ type: 'text', text: tail }] };
|
|
1604
1806
|
}
|
|
1605
1807
|
|
|
1606
1808
|
case 'upload_file': {
|
|
1809
|
+
const config = await loadConfig();
|
|
1607
1810
|
const { client } = await getClient(args.projectName);
|
|
1608
1811
|
const res = await client.uploadFile({
|
|
1812
|
+
push: resolvePush(config.settings, args),
|
|
1609
1813
|
srcPath: args.srcPath,
|
|
1610
1814
|
destPath: args.destPath,
|
|
1611
1815
|
files: args.files,
|
|
@@ -1613,9 +1817,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1613
1817
|
overwrite: args.overwrite,
|
|
1614
1818
|
commitMessage: args.commitMessage,
|
|
1615
1819
|
});
|
|
1616
|
-
const tail = res.
|
|
1617
|
-
? `
|
|
1618
|
-
: `
|
|
1820
|
+
const tail = res.reason
|
|
1821
|
+
? `No upload performed (${res.reason}).`
|
|
1822
|
+
: `${mutationTail(res, `Uploaded ${res.files.length} file(s): ${res.files.join(', ')}`)} Reference each figure with \\includegraphics{...} via edit_file.`;
|
|
1619
1823
|
return { content: [{ type: 'text', text: tail }] };
|
|
1620
1824
|
}
|
|
1621
1825
|
|
|
@@ -1628,9 +1832,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1628
1832
|
}
|
|
1629
1833
|
|
|
1630
1834
|
case 'add_citation': {
|
|
1835
|
+
const config = await loadConfig();
|
|
1631
1836
|
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.
|
|
1837
|
+
const res = await client.addCitation({ entry: args.entry, commitMessage: args.commitMessage, push: resolvePush(config.settings, args) });
|
|
1838
|
+
return { content: [{ type: 'text', text: res.reason ? `No change for "${res.key}" (${res.reason}).` : mutationTail(res, `Added citation "${res.key}" to refs.bib`) }] };
|
|
1634
1839
|
}
|
|
1635
1840
|
|
|
1636
1841
|
case 'cite_lint': {
|
|
@@ -1649,22 +1854,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1649
1854
|
}
|
|
1650
1855
|
|
|
1651
1856
|
case 'restore': {
|
|
1857
|
+
const config = await loadConfig();
|
|
1652
1858
|
const { client } = await getClient(args.projectName);
|
|
1653
|
-
const r = await client.restore(args.label);
|
|
1654
|
-
return { content: [{ type: 'text', text: `Restored "${r.label}"
|
|
1859
|
+
const r = await client.restore(args.label, { push: resolvePush(config.settings, args) });
|
|
1860
|
+
return { content: [{ type: 'text', text: `${mutationTail(r, `Restored "${r.label}" as a forward commit`)} Run verify_build to confirm.` }] };
|
|
1655
1861
|
}
|
|
1656
1862
|
|
|
1657
1863
|
case 'voice_lint': {
|
|
1658
1864
|
const config = await loadConfig();
|
|
1659
1865
|
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 });
|
|
1866
|
+
const r = await client.voiceLint(args.filePath, { command: voiceLinterCommand(config) });
|
|
1668
1867
|
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
1868
|
}
|
|
1670
1869
|
|
|
@@ -1693,14 +1892,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1693
1892
|
throw new Error(`Unknown tool: ${name}`);
|
|
1694
1893
|
}
|
|
1695
1894
|
} 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
|
-
};
|
|
1895
|
+
return toolError(error);
|
|
1702
1896
|
}
|
|
1703
|
-
});
|
|
1897
|
+
})());
|
|
1704
1898
|
|
|
1705
1899
|
async function main() {
|
|
1706
1900
|
const transport = new StdioServerTransport();
|