overleaf-forge 2.7.1
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/LICENSE +22 -0
- package/README.md +277 -0
- package/overleaf-mcp-server.js +1672 -0
- package/package.json +48 -0
- package/projects.example.json +22 -0
- package/templates/context-scaffold.md +34 -0
- package/templates/main.tex +169 -0
- package/writing-guidelines.md +245 -0
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { readFile, writeFile, access, mkdir, readdir, stat, rm, rename, copyFile } from 'fs/promises';
|
|
10
|
+
import { existsSync, realpathSync } from 'fs';
|
|
11
|
+
import { promisify } from 'util';
|
|
12
|
+
import { execFile as execFileCallback } from 'child_process';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
// Every subprocess call goes through execFile (no shell): no shell injection and
|
|
21
|
+
// no secrets in command strings. Git auth is supplied via an inline credential
|
|
22
|
+
// helper that reads the token from the environment (see OverleafGitClient._git).
|
|
23
|
+
const execFile = promisify(execFileCallback);
|
|
24
|
+
|
|
25
|
+
// --- Paths: bundled assets vs. writable user state --------------------------
|
|
26
|
+
// Bundled, read-only defaults (templates, the default writing-guidelines) ship
|
|
27
|
+
// inside the package and are read from PACKAGE_DIR. User state (the token
|
|
28
|
+
// config, per-project contexts, customised templates, and git clones) must live
|
|
29
|
+
// somewhere writable, because the package can run from an immutable npm/npx
|
|
30
|
+
// cache. Anything a user edits resolves user-copy-first, bundled-default-last.
|
|
31
|
+
const PACKAGE_DIR = __dirname;
|
|
32
|
+
|
|
33
|
+
// Expand a leading ~ to the user's home. Plain join elsewhere assumes absolute.
|
|
34
|
+
function expandHome(p) {
|
|
35
|
+
if (!p) return p;
|
|
36
|
+
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Where user state lives. Precedence:
|
|
40
|
+
// 1. $OVERLEAF_MCP_HOME (explicit override)
|
|
41
|
+
// 2. PACKAGE_DIR, if it already holds a projects.json (existing local clones)
|
|
42
|
+
// 3. ~/.overleaf-mcp (fresh npx / global installs)
|
|
43
|
+
// Pure (FS facts injected) so the precedence is unit-testable.
|
|
44
|
+
export function resolveDataHome({ env, packageDir, homeDir, hasPackageConfig }) {
|
|
45
|
+
const override = (env.OVERLEAF_MCP_HOME || '').trim();
|
|
46
|
+
if (override) return override.startsWith('~') ? path.join(homeDir, override.slice(1)) : override;
|
|
47
|
+
if (hasPackageConfig) return packageDir;
|
|
48
|
+
return path.join(homeDir, '.overleaf-mcp');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Build a single-project config from environment variables alone, so the server
|
|
52
|
+
// runs with zero config files (the common LM Studio / Claude Desktop case: token
|
|
53
|
+
// and project id passed via the client's `env` block). Returns null when the env
|
|
54
|
+
// doesn't carry enough to act on.
|
|
55
|
+
export function synthesizeConfigFromEnv(env, defaultRepoDir) {
|
|
56
|
+
const gitToken = env.OVERLEAF_GIT_TOKEN;
|
|
57
|
+
const projectId = env.OVERLEAF_PROJECT_ID;
|
|
58
|
+
if (!gitToken || !projectId) return null;
|
|
59
|
+
return {
|
|
60
|
+
settings: { gitToken, repoDir: defaultRepoDir },
|
|
61
|
+
projects: {
|
|
62
|
+
default: {
|
|
63
|
+
name: env.OVERLEAF_PROJECT_NAME || 'Overleaf Project',
|
|
64
|
+
projectId,
|
|
65
|
+
localPath: path.join(defaultRepoDir, 'default'),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DATA_HOME = resolveDataHome({
|
|
72
|
+
env: process.env,
|
|
73
|
+
packageDir: PACKAGE_DIR,
|
|
74
|
+
homeDir: os.homedir(),
|
|
75
|
+
hasPackageConfig: existsSync(path.join(PACKAGE_DIR, 'projects.json')),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const CONFIG_PATH = path.join(DATA_HOME, 'projects.json');
|
|
79
|
+
const CONTEXTS_DIR = path.join(DATA_HOME, 'contexts');
|
|
80
|
+
const DEFAULT_REPO_DIR = path.join(DATA_HOME, 'repos');
|
|
81
|
+
const BUNDLED_TEMPLATES_DIR = path.join(PACKAGE_DIR, 'templates');
|
|
82
|
+
// A user copy in the data home overrides the bundled default writing-guidelines.
|
|
83
|
+
const GUIDELINES_PATH = existsSync(path.join(DATA_HOME, 'writing-guidelines.md'))
|
|
84
|
+
? path.join(DATA_HOME, 'writing-guidelines.md')
|
|
85
|
+
: path.join(PACKAGE_DIR, 'writing-guidelines.md');
|
|
86
|
+
|
|
87
|
+
// Where scaffold templates (main.tex skeleton, context-scaffold.md) are read.
|
|
88
|
+
// Precedence: settings.templatesDir → $OVERLEAF_MCP_TEMPLATES → ~/.overleaf-mcp/
|
|
89
|
+
// templates (if present) → bundled defaults. Lets a user customise the scaffolds.
|
|
90
|
+
function resolveTemplatesDir(config) {
|
|
91
|
+
const fromSettings = config?.settings?.templatesDir;
|
|
92
|
+
if (fromSettings) return expandHome(fromSettings);
|
|
93
|
+
const fromEnv = (process.env.OVERLEAF_MCP_TEMPLATES || '').trim();
|
|
94
|
+
if (fromEnv) return expandHome(fromEnv);
|
|
95
|
+
const inHome = path.join(DATA_HOME, 'templates');
|
|
96
|
+
if (existsSync(inHome)) return inHome;
|
|
97
|
+
return BUNDLED_TEMPLATES_DIR;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Read a named template from the resolved templates dir; null if absent so the
|
|
101
|
+
// caller can fall back to a built-in default (templates must never hard-fail).
|
|
102
|
+
async function loadTemplate(config, filename) {
|
|
103
|
+
try { return await readFile(path.join(resolveTemplatesDir(config), filename), 'utf-8'); }
|
|
104
|
+
catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Built-in fallback for templates/context-scaffold.md, used when the user has
|
|
108
|
+
// not supplied their own. __SSA_NAME__ is substituted at scaffold time.
|
|
109
|
+
const DEFAULT_CONTEXT_SCAFFOLD = `# __SSA_NAME__
|
|
110
|
+
|
|
111
|
+
> Fill these in. Each block is here because past SSAs have needed it. Drop blocks that genuinely do not apply, but do not leave them blank — empty context is the most common reason Claude drifts.
|
|
112
|
+
|
|
113
|
+
## Topic and scope
|
|
114
|
+
- What is this SSA actually about? One paragraph in your own words.
|
|
115
|
+
- What is in scope, and what is explicitly out of scope?
|
|
116
|
+
|
|
117
|
+
## Deadline and submission
|
|
118
|
+
- Hard deadline (date + time):
|
|
119
|
+
- Submission target (Canvas, hand-in, etc.):
|
|
120
|
+
- Page or length limit, if any:
|
|
121
|
+
|
|
122
|
+
## Collaborators and division of labour
|
|
123
|
+
- Who else is on this SSA:
|
|
124
|
+
- Who is doing what:
|
|
125
|
+
- What you specifically own:
|
|
126
|
+
|
|
127
|
+
## Pinned decisions
|
|
128
|
+
- Decisions already made that should not be re-litigated mid-draft:
|
|
129
|
+
|
|
130
|
+
## Source material
|
|
131
|
+
- Lecture notes / chapters / datasheets / papers you are working from:
|
|
132
|
+
- Citation style (default IEEE):
|
|
133
|
+
|
|
134
|
+
## Structure constraints
|
|
135
|
+
- Required sections (Goals, Summary, Details, etc.) in order:
|
|
136
|
+
- Anything unusual (e.g. an appendix the assignment requires):
|
|
137
|
+
|
|
138
|
+
## Known unknowns
|
|
139
|
+
- Things that are still open and will need updating:
|
|
140
|
+
|
|
141
|
+
## Other context
|
|
142
|
+
- Anything else Claude should keep in mind throughout the write-up:
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
// Capture the cwd Claude Code spawned this MCP from. Used for project autodetection.
|
|
146
|
+
const SESSION_CWD = process.cwd();
|
|
147
|
+
|
|
148
|
+
// Re-read on every call so register_project / token rotation / context edits
|
|
149
|
+
// take effect immediately without restarting Claude. A missing config is not an
|
|
150
|
+
// error: fall back to env-only mode, then to an empty config so the server still
|
|
151
|
+
// starts and tools return actionable messages instead of dying at spawn.
|
|
152
|
+
async function loadConfig() {
|
|
153
|
+
try {
|
|
154
|
+
const raw = await readFile(CONFIG_PATH, 'utf-8');
|
|
155
|
+
return JSON.parse(raw);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err.code !== 'ENOENT') throw err; // corrupt / unreadable: surface it
|
|
158
|
+
const fromEnv = synthesizeConfigFromEnv(process.env, DEFAULT_REPO_DIR);
|
|
159
|
+
if (fromEnv) return fromEnv;
|
|
160
|
+
return { settings: {}, projects: {} };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function saveConfig(config) {
|
|
165
|
+
// Ensure the data home exists (env-only / fresh installs may not have it yet),
|
|
166
|
+
// then write-temp-then-rename so a crash mid-write can't corrupt projects.json
|
|
167
|
+
// (it holds the token and every project entry).
|
|
168
|
+
await mkdir(path.dirname(CONFIG_PATH), { recursive: true });
|
|
169
|
+
const tmp = `${CONFIG_PATH}.tmp`;
|
|
170
|
+
await writeFile(tmp, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
171
|
+
await rename(tmp, CONFIG_PATH);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveGitToken(config, project) {
|
|
175
|
+
return project.gitToken || config.settings?.gitToken || process.env.OVERLEAF_GIT_TOKEN;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveLocalPath(config, projectKey, project) {
|
|
179
|
+
if (project.localPath) return project.localPath;
|
|
180
|
+
if (config.settings?.repoDir) {
|
|
181
|
+
return path.join(config.settings.repoDir, project.name || projectKey);
|
|
182
|
+
}
|
|
183
|
+
return path.join(DEFAULT_REPO_DIR, project.name || projectKey);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Default project resolution:
|
|
187
|
+
// 1. explicit projectName argument
|
|
188
|
+
// 2. project whose `cwd` is a prefix of SESSION_CWD (longest match wins)
|
|
189
|
+
// 3. project keyed "default"
|
|
190
|
+
// 4. first project in file
|
|
191
|
+
function pickProjectKey(config, requested) {
|
|
192
|
+
// An explicit request must resolve to a known project. If it cannot, throw --
|
|
193
|
+
// never fall through to CWD autodetection, which is how writes used to land
|
|
194
|
+
// silently in the wrong project.
|
|
195
|
+
if (requested) {
|
|
196
|
+
if (config.projects[requested]) return requested;
|
|
197
|
+
const lc = String(requested).trim().toLowerCase();
|
|
198
|
+
for (const [key, p] of Object.entries(config.projects)) {
|
|
199
|
+
if (key.toLowerCase() === lc) return key;
|
|
200
|
+
if (p.name && p.name.toLowerCase() === lc) return key;
|
|
201
|
+
}
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Project "${requested}" not found. Known keys: ${Object.keys(config.projects).join(', ') || '(none)'}. ` +
|
|
204
|
+
`Pass an exact key/name, or omit projectName to auto-detect from the current directory.`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// No explicit request: auto-detect from the session CWD (longest cwd prefix wins).
|
|
209
|
+
if (SESSION_CWD) {
|
|
210
|
+
let best = null;
|
|
211
|
+
let bestLen = -1;
|
|
212
|
+
for (const [key, p] of Object.entries(config.projects)) {
|
|
213
|
+
if (p.cwd && (SESSION_CWD === p.cwd || SESSION_CWD.startsWith(p.cwd + path.sep))) {
|
|
214
|
+
if (p.cwd.length > bestLen) {
|
|
215
|
+
best = key;
|
|
216
|
+
bestLen = p.cwd.length;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (best) return best;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (config.projects.default) return 'default';
|
|
224
|
+
const keys = Object.keys(config.projects);
|
|
225
|
+
if (keys.length) return keys[0];
|
|
226
|
+
throw new Error('No projects configured');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Pure: classify a latexmk/LaTeX log into a build verdict. Exported for tests.
|
|
230
|
+
// pass iff a PDF was produced AND there are no errors, undefined references, or
|
|
231
|
+
// undefined citations. Overfull/underfull boxes are reported but never fail.
|
|
232
|
+
export function classifyBuildLog(log) {
|
|
233
|
+
const text = String(log || '');
|
|
234
|
+
const pageMatches = [...text.matchAll(/Output written on [^\n(]*\((\d+) pages?/g)];
|
|
235
|
+
const pageCount = pageMatches.length ? Number(pageMatches[pageMatches.length - 1][1]) : null;
|
|
236
|
+
const pdfProduced = pageCount !== null;
|
|
237
|
+
const undefinedRefs = text.match(/^LaTeX Warning: Reference [^\n]*undefined[^\n]*/gim) || [];
|
|
238
|
+
const undefinedCitations = text.match(/^(?:LaTeX|Package natbib|Package biblatex)[^\n]*Warning:[^\n]*Citation[^\n]*undefined[^\n]*/gim) || [];
|
|
239
|
+
const errors = (text.match(/^! [^\n]*/gm) || [])
|
|
240
|
+
.concat(text.match(/^(?:Latexmk|Fatal error)[^\n]*(?:error|failed|fatal)[^\n]*/gim) || []);
|
|
241
|
+
const overfullCount = (text.match(/^Overfull \\[hv]box/gm) || []).length;
|
|
242
|
+
const underfullCount = (text.match(/^Underfull \\[hv]box/gm) || []).length;
|
|
243
|
+
const pass = pdfProduced && errors.length === 0 && undefinedRefs.length === 0 && undefinedCitations.length === 0;
|
|
244
|
+
return { pass, pageCount, undefinedRefs, undefinedCitations, errors, overfullCount, underfullCount, pdfProduced };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
class OverleafGitClient {
|
|
248
|
+
constructor(projectId, gitToken, localPath, gitUrlOverride) {
|
|
249
|
+
this.projectId = projectId;
|
|
250
|
+
this.gitToken = gitToken;
|
|
251
|
+
this.repoPath = localPath;
|
|
252
|
+
this.gitUrl = gitUrlOverride || `https://git.overleaf.com/${projectId}`; // override for tests / token-free
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Run git without a shell. For authenticated remote ops the token is provided
|
|
256
|
+
// through an inline credential helper that reads it from the environment, so
|
|
257
|
+
// the token never appears in argv and therefore never in an error message.
|
|
258
|
+
async _git(args, { auth = false } = {}) {
|
|
259
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
260
|
+
const pre = [];
|
|
261
|
+
if (auth) {
|
|
262
|
+
if (!this.gitToken) {
|
|
263
|
+
throw new Error('No Overleaf git token configured. Set settings.gitToken in projects.json or OVERLEAF_GIT_TOKEN env var.');
|
|
264
|
+
}
|
|
265
|
+
env.OVERLEAF_TOKEN = this.gitToken;
|
|
266
|
+
pre.push(
|
|
267
|
+
'-c', 'credential.helper=',
|
|
268
|
+
'-c', 'credential.helper=!f() { test "$1" = get && echo username=git && echo "password=$OVERLEAF_TOKEN"; }; f'
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return execFile('git', [...pre, ...args], { env, maxBuffer: 20 * 1024 * 1024 });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async _hasRepo() {
|
|
275
|
+
try { await access(path.join(this.repoPath, '.git')); return true; } catch { return false; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async cloneOrPull() {
|
|
279
|
+
if (!this.gitToken) {
|
|
280
|
+
throw new Error('No Overleaf git token configured. Set settings.gitToken in projects.json or OVERLEAF_GIT_TOKEN env var.');
|
|
281
|
+
}
|
|
282
|
+
if (!(await this._hasRepo())) {
|
|
283
|
+
await mkdir(path.dirname(this.repoPath), { recursive: true });
|
|
284
|
+
const { stdout } = await this._git(['clone', this.gitUrl, this.repoPath], { auth: true });
|
|
285
|
+
return stdout;
|
|
286
|
+
}
|
|
287
|
+
// Repair older clones that embedded the token in the remote URL.
|
|
288
|
+
await this._git(['-C', this.repoPath, 'remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
|
|
289
|
+
try {
|
|
290
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'pull', '--ff-only'], { auth: true });
|
|
291
|
+
return stdout;
|
|
292
|
+
} catch {
|
|
293
|
+
// Pull failed (diverged, or leftover changes from a prior failed write).
|
|
294
|
+
// Overleaf is the source of truth, so fetch and hard-reset to the remote
|
|
295
|
+
// tip rather than cascading into a clone-into-nonempty-dir error.
|
|
296
|
+
await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true });
|
|
297
|
+
const { stdout: br } = await this._git(['-C', this.repoPath, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
298
|
+
const branch = (br || '').trim() || 'master';
|
|
299
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]);
|
|
300
|
+
return `recovered: hard-reset to origin/${branch}`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async listFiles(extension = '.tex') {
|
|
305
|
+
await this.cloneOrPull();
|
|
306
|
+
const out = [];
|
|
307
|
+
const walk = async (dir) => {
|
|
308
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
309
|
+
for (const e of entries) {
|
|
310
|
+
if (e.name === '.git') continue;
|
|
311
|
+
const full = path.join(dir, e.name);
|
|
312
|
+
if (e.isDirectory()) await walk(full);
|
|
313
|
+
else if (e.isFile() && (!extension || e.name.endsWith(extension))) {
|
|
314
|
+
out.push(path.relative(this.repoPath, full));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
await walk(this.repoPath);
|
|
319
|
+
return out.sort();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async readFile(filePath) {
|
|
323
|
+
await this.cloneOrPull();
|
|
324
|
+
const fullPath = path.join(this.repoPath, filePath);
|
|
325
|
+
return await readFile(fullPath, 'utf-8');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// git blob SHA of a file at the current tip; null if the file isn't tracked.
|
|
329
|
+
async getBlobSha(filePath, { pull = true } = {}) {
|
|
330
|
+
if (pull) await this.cloneOrPull();
|
|
331
|
+
try {
|
|
332
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', `HEAD:${filePath}`]);
|
|
333
|
+
return stdout.trim();
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async getSections(filePath) {
|
|
340
|
+
const content = await this.readFile(filePath);
|
|
341
|
+
const sections = [];
|
|
342
|
+
// Allow \section* and one level of nested braces in the title
|
|
343
|
+
// (e.g. \section{The \textbf{bold} title}).
|
|
344
|
+
const sectionRegex = /\\(section|subsection|subsubsection)\*?\{((?:[^{}]|\{[^{}]*\})*)\}/g;
|
|
345
|
+
let match;
|
|
346
|
+
while ((match = sectionRegex.exec(content)) !== null) {
|
|
347
|
+
sections.push({ title: match[2], type: match[1], index: match.index });
|
|
348
|
+
}
|
|
349
|
+
return sections;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Run latexmk from the repo root (so the project's .latexmkrc -- shell-escape,
|
|
353
|
+
// the python@3.13 PATH fix for minted, $pdf_mode -- applies, and refs/citations/
|
|
354
|
+
// reruns resolve). clean:true adds -gg to force a complete from-scratch rebuild.
|
|
355
|
+
async _runLatexmk(filePath, engine = 'lualatex', { clean = false } = {}) {
|
|
356
|
+
await this.cloneOrPull();
|
|
357
|
+
const engineFlag = { pdflatex: '-pdf', xelatex: '-xelatex', lualatex: '-lualatex' }[engine];
|
|
358
|
+
if (!engineFlag) {
|
|
359
|
+
throw new Error(`Invalid engine "${engine}". Choose from: pdflatex, xelatex, lualatex`);
|
|
360
|
+
}
|
|
361
|
+
const texbin = '/Library/TeX/texbin';
|
|
362
|
+
const env = { ...process.env, PATH: `${texbin}:${process.env.PATH || ''}` };
|
|
363
|
+
const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error'];
|
|
364
|
+
if (clean) args.push('-gg');
|
|
365
|
+
args.push(filePath);
|
|
366
|
+
const { stdout, stderr } = await execFile(
|
|
367
|
+
path.join(texbin, 'latexmk'), args,
|
|
368
|
+
{ cwd: this.repoPath, timeout: 180000, maxBuffer: 20 * 1024 * 1024, env }
|
|
369
|
+
).catch(e => ({ stdout: e.stdout || '', stderr: e.stderr || e.message }));
|
|
370
|
+
const pdfPath = path.join(this.repoPath, filePath.replace(/\.tex$/, '.pdf'));
|
|
371
|
+
let pdfExists = false;
|
|
372
|
+
try { await access(pdfPath); pdfExists = true; } catch { /* no pdf */ }
|
|
373
|
+
return { stdout, stderr, log: `${stdout}\n${stderr}`, pdfPath: pdfExists ? pdfPath : null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async compileFile(filePath, engine = 'lualatex') {
|
|
377
|
+
const { log, pdfPath } = await this._runLatexmk(filePath, engine, { clean: false });
|
|
378
|
+
const errors = (log.match(/^!.*$/gm) || []).slice(0, 20);
|
|
379
|
+
const undefinedRefs = (log.match(/^(?:LaTeX|Package)[^\n]*Warning:[^\n]*(?:undefined|multiply)[^\n]*/gmi) || []);
|
|
380
|
+
const overfull = (log.match(/^(?:Overfull|Underfull)[^\n]*$/gm) || []).slice(0, 20);
|
|
381
|
+
return { pdfPath, errors, undefinedRefs, overfull, tail: log.slice(-2500) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Clean-from-scratch build + structured PASS/FAIL verdict on the "done" bar.
|
|
385
|
+
async verifyBuild(filePath, engine = 'lualatex') {
|
|
386
|
+
const { log: runLog, pdfPath } = await this._runLatexmk(filePath, engine, { clean: true });
|
|
387
|
+
// Classify the FINAL-pass log (e.g. main.log), NOT latexmk's concatenated
|
|
388
|
+
// multi-pass stdout: pass 1 (before the .aux exists) flags every \ref/\cite
|
|
389
|
+
// undefined, and those transient warnings would be false positives. main.log
|
|
390
|
+
// is the last engine run's output -- the true end state; a genuinely undefined
|
|
391
|
+
// ref persists there, a resolved one does not. Fall back to the run log if the
|
|
392
|
+
// .log file is missing (a catastrophic failure that produced no .log).
|
|
393
|
+
const logFile = path.join(this.repoPath, filePath.replace(/\.tex$/, '.log'));
|
|
394
|
+
let finalLog = runLog;
|
|
395
|
+
try { finalLog = await readFile(logFile, 'utf-8'); } catch { /* keep runLog */ }
|
|
396
|
+
const verdict = classifyBuildLog(finalLog);
|
|
397
|
+
// Confirm the PDF against the real file, not just the log, so a parser miss
|
|
398
|
+
// can't yield a false PASS; then recompute the verdict.
|
|
399
|
+
verdict.pdfProduced = pdfPath !== null;
|
|
400
|
+
verdict.pass = verdict.pdfProduced
|
|
401
|
+
&& verdict.errors.length === 0
|
|
402
|
+
&& verdict.undefinedRefs.length === 0
|
|
403
|
+
&& verdict.undefinedCitations.length === 0;
|
|
404
|
+
verdict.tail = finalLog.slice(-2500);
|
|
405
|
+
return verdict;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Read-only grep across tracked files. Regex by default; fixed -> -F; ignoreCase -> -i.
|
|
409
|
+
async searchText({ query, fixed = false, ignoreCase = false, extension } = {}) {
|
|
410
|
+
if (!query) throw new Error('search_text needs a query.');
|
|
411
|
+
await this.cloneOrPull();
|
|
412
|
+
const args = ['-C', this.repoPath, 'grep', '-n', '--no-color', fixed ? '-F' : '-E'];
|
|
413
|
+
if (ignoreCase) args.push('-i');
|
|
414
|
+
args.push('-e', query);
|
|
415
|
+
if (extension) args.push('--', `*${extension}`);
|
|
416
|
+
try {
|
|
417
|
+
const { stdout } = await this._git(args);
|
|
418
|
+
const lines = stdout.split('\n').filter(Boolean);
|
|
419
|
+
return { matches: lines.slice(0, 200), total: lines.length };
|
|
420
|
+
} catch (e) {
|
|
421
|
+
if (e.code === 1 && !(e.stderr && e.stderr.trim())) return { matches: [], total: 0 }; // git grep: no matches
|
|
422
|
+
throw e;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Append a BibTeX entry to refs.bib (reject a duplicate key), commit, push.
|
|
427
|
+
async addCitation({ entry, commitMessage } = {}) {
|
|
428
|
+
if (!entry || !entry.trim()) throw new Error('add_citation needs a BibTeX entry.');
|
|
429
|
+
const m = entry.match(/@\w+\s*\{\s*([^,\s]+)/);
|
|
430
|
+
if (!m) throw new Error('Could not find a BibTeX key in the entry (expected @type{key, ...}).');
|
|
431
|
+
const key = m[1];
|
|
432
|
+
await this.cloneOrPull();
|
|
433
|
+
const bibPath = path.join(this.repoPath, 'refs.bib');
|
|
434
|
+
let current = '';
|
|
435
|
+
try { current = await readFile(bibPath, 'utf-8'); } catch { /* missing -> create */ }
|
|
436
|
+
const dup = new RegExp(`@\\w+\\s*\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*,`);
|
|
437
|
+
if (dup.test(current)) throw new Error(`citation key "${key}" already in refs.bib.`);
|
|
438
|
+
await writeFile(bibPath, current.replace(/\s*$/, '') + '\n\n' + entry.trim() + '\n', 'utf-8');
|
|
439
|
+
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
440
|
+
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
441
|
+
await this._git(['-C', this.repoPath, 'add', '--', 'refs.bib']);
|
|
442
|
+
try {
|
|
443
|
+
await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Add citation ${key}`]);
|
|
444
|
+
} catch (e) {
|
|
445
|
+
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) return { pushed: false, reason: 'nothing to commit', key };
|
|
446
|
+
throw e;
|
|
447
|
+
}
|
|
448
|
+
return { ...(await this._pushWithMerge()), key };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Read-only: cited keys (across .tex) vs defined keys (refs.bib).
|
|
452
|
+
async citeLint() {
|
|
453
|
+
await this.cloneOrPull();
|
|
454
|
+
const texFiles = await this.listFiles('.tex');
|
|
455
|
+
const citeRe = /\\(?:cite|autocite|parencite|citep|citet|textcite|footcite|nocite)\*?(?:\[[^\]]*\])*\{([^}]+)\}/g;
|
|
456
|
+
const cited = new Set();
|
|
457
|
+
for (const f of texFiles) {
|
|
458
|
+
const content = await readFile(path.join(this.repoPath, f), 'utf-8');
|
|
459
|
+
let m;
|
|
460
|
+
while ((m = citeRe.exec(content)) !== null) {
|
|
461
|
+
for (const k of m[1].split(',')) {
|
|
462
|
+
const key = k.trim();
|
|
463
|
+
if (key && key !== '*') cited.add(key);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
let bib = '';
|
|
468
|
+
try { bib = await readFile(path.join(this.repoPath, 'refs.bib'), 'utf-8'); } catch { /* none */ }
|
|
469
|
+
const defined = new Set();
|
|
470
|
+
let bm;
|
|
471
|
+
const defRe = /@(\w+)\s*\{\s*([^,\s]+)/g;
|
|
472
|
+
while ((bm = defRe.exec(bib)) !== null) {
|
|
473
|
+
const type = bm[1].toLowerCase();
|
|
474
|
+
if (type === 'string' || type === 'comment' || type === 'preamble') continue;
|
|
475
|
+
defined.add(bm[2]);
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
undefined: [...cited].filter(k => !defined.has(k)).sort(),
|
|
479
|
+
unused: [...defined].filter(k => !cited.has(k)).sort(),
|
|
480
|
+
cited: cited.size,
|
|
481
|
+
defined: defined.size,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Local rollback point: a lightweight tag mcp-snap/<label> at HEAD (not pushed).
|
|
486
|
+
async checkpoint(label) {
|
|
487
|
+
await this.cloneOrPull();
|
|
488
|
+
const name = `mcp-snap/${(label && label.trim()) || `snap-${Date.now()}`}`;
|
|
489
|
+
let exists = false;
|
|
490
|
+
try { await this._git(['-C', this.repoPath, 'rev-parse', '--verify', '--quiet', `refs/tags/${name}`]); exists = true; } catch { exists = false; }
|
|
491
|
+
if (exists) throw new Error(`snapshot "${name}" already exists; choose a different label.`);
|
|
492
|
+
await this._git(['-C', this.repoPath, 'tag', name]);
|
|
493
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', 'HEAD']);
|
|
494
|
+
return { label: name, head: stdout.trim() };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Forward-restore the snapshot's tree as a new commit on top of HEAD, then push.
|
|
498
|
+
// No history rewrite, no force-push (the new commit descends from HEAD).
|
|
499
|
+
async restore(label) {
|
|
500
|
+
await this.cloneOrPull();
|
|
501
|
+
const name = String(label || '').startsWith('mcp-snap/') ? label : `mcp-snap/${label}`;
|
|
502
|
+
let tree;
|
|
503
|
+
try { ({ stdout: tree } = await this._git(['-C', this.repoPath, 'rev-parse', `${name}^{tree}`])); }
|
|
504
|
+
catch {
|
|
505
|
+
const tags = await this._git(['-C', this.repoPath, 'tag', '--list', 'mcp-snap/*']).then(r => r.stdout.trim()).catch(() => '');
|
|
506
|
+
throw new Error(`snapshot "${name}" not found. Available: ${tags || '(none)'}`);
|
|
507
|
+
}
|
|
508
|
+
tree = tree.trim();
|
|
509
|
+
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
510
|
+
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
511
|
+
const { stdout: commit } = await this._git(['-C', this.repoPath, 'commit-tree', tree, '-p', 'HEAD', '-m', `restore: ${name}`]);
|
|
512
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', commit.trim()]);
|
|
513
|
+
try {
|
|
514
|
+
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
515
|
+
} catch (e) {
|
|
516
|
+
const branch = await this._currentBranch();
|
|
517
|
+
await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
|
|
518
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
519
|
+
throw new Error(`restore: Overleaf moved during the rollback; refused. Re-run after re-pulling. (${(e.stderr || e.message || '').slice(0, 120)})`);
|
|
520
|
+
}
|
|
521
|
+
return { pushed: true, label: name, restoredTo: tree };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Run the configured voice linter on a file; advisory, read-only.
|
|
525
|
+
// exit 0 -> clean; exit 2 -> findings (stderr); other -> error. command is
|
|
526
|
+
// tokenized (no shell); a leading ~ in any token expands to the home dir.
|
|
527
|
+
async voiceLint(filePath, { command } = {}) {
|
|
528
|
+
if (!command || !command.trim()) {
|
|
529
|
+
throw new Error('No voice linter configured. Set settings.voiceLinter in projects.json or the OVERLEAF_VOICE_LINTER env var (e.g. a command that takes a file path and exits non-zero on findings).');
|
|
530
|
+
}
|
|
531
|
+
await this.cloneOrPull();
|
|
532
|
+
const abs = path.join(this.repoPath, filePath);
|
|
533
|
+
const home = os.homedir();
|
|
534
|
+
const parts = command.trim().split(/\s+/).map(t => (t.startsWith('~') ? home + t.slice(1) : t));
|
|
535
|
+
const [prog, ...rest] = parts;
|
|
536
|
+
try {
|
|
537
|
+
const { stdout, stderr } = await execFile(prog, [...rest, abs], { maxBuffer: 4 * 1024 * 1024 });
|
|
538
|
+
return { clean: true, findings: (stderr || stdout || '').trim() };
|
|
539
|
+
} catch (e) {
|
|
540
|
+
if (e.code === 2) return { clean: false, findings: (e.stderr || e.stdout || '').trim() };
|
|
541
|
+
throw new Error(`voice linter failed (exit ${e.code}): ${(e.stderr || e.message || '').slice(0, 200)}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async commitAndPush(message, { addAll = false, addPath } = {}) {
|
|
546
|
+
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
547
|
+
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
548
|
+
if (addAll) await this._git(['-C', this.repoPath, 'add', '-A']);
|
|
549
|
+
else if (addPath) await this._git(['-C', this.repoPath, 'add', '--', addPath]);
|
|
550
|
+
try {
|
|
551
|
+
await this._git(['-C', this.repoPath, 'commit', '-m', message]);
|
|
552
|
+
} catch (e) {
|
|
553
|
+
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
|
|
554
|
+
return { pushed: false, reason: 'nothing to commit' };
|
|
555
|
+
}
|
|
556
|
+
throw e;
|
|
557
|
+
}
|
|
558
|
+
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
559
|
+
return { pushed: true };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async _currentBranch() {
|
|
563
|
+
const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
564
|
+
return (stdout || '').trim() || 'master';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Push origin HEAD. If the remote moved during the op (non-fast-forward),
|
|
568
|
+
// let git 3-way merge it: clean merge -> push; real conflict -> abort, reset
|
|
569
|
+
// to the remote tip, and throw (so nothing half-applied is left behind).
|
|
570
|
+
async _pushWithMerge() {
|
|
571
|
+
try {
|
|
572
|
+
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
573
|
+
return { pushed: true, merged: false };
|
|
574
|
+
} catch {
|
|
575
|
+
const branch = await this._currentBranch();
|
|
576
|
+
await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true });
|
|
577
|
+
try {
|
|
578
|
+
await this._git(['-C', this.repoPath, 'merge', '--no-edit', `origin/${branch}`]);
|
|
579
|
+
} catch (mergeErr) {
|
|
580
|
+
await this._git(['-C', this.repoPath, 'merge', '--abort']).catch(() => {});
|
|
581
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]);
|
|
582
|
+
const e = new Error('conflict: the file changed on Overleaf in a way that overlaps this edit. Re-read the file and retry.');
|
|
583
|
+
e.cause = mergeErr;
|
|
584
|
+
throw e;
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
588
|
+
} catch (e2) {
|
|
589
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
590
|
+
const e = new Error('conflict: Overleaf moved again while merging; reset clean — re-read the file and retry.');
|
|
591
|
+
e.cause = e2;
|
|
592
|
+
throw e;
|
|
593
|
+
}
|
|
594
|
+
return { pushed: true, merged: true };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Full-file create / wholesale overwrite. New files: created freely. Existing
|
|
599
|
+
// files: require either a matching baseSha (proves freshness) or overwrite:true
|
|
600
|
+
// (a deliberate clobber). A stale baseSha is refused, never merged.
|
|
601
|
+
async writeFile(filePath, content, opts = {}) {
|
|
602
|
+
const { baseSha, overwrite = false, commitMessage } = opts;
|
|
603
|
+
await this.cloneOrPull();
|
|
604
|
+
const fullPath = path.join(this.repoPath, filePath);
|
|
605
|
+
const current = await this.getBlobSha(filePath, { pull: false }); // null if new
|
|
606
|
+
|
|
607
|
+
if (current !== null) {
|
|
608
|
+
if (baseSha != null) {
|
|
609
|
+
if (baseSha !== current) {
|
|
610
|
+
throw new Error(`${filePath} changed on Overleaf since baseSha ${String(baseSha).slice(0, 12)} (now ${current.slice(0, 12)}); the write is stale. Re-read and retry, or use edit_file for a surgical change.`);
|
|
611
|
+
}
|
|
612
|
+
} else if (!overwrite) {
|
|
613
|
+
throw new Error(`${filePath} already exists. Pass baseSha (from read_file) to overwrite the version you read, set overwrite:true to force, or use edit_file for a surgical change.`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
618
|
+
await writeFile(fullPath, content, 'utf-8');
|
|
619
|
+
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
620
|
+
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
621
|
+
await this._git(['-C', this.repoPath, 'add', '--', filePath]);
|
|
622
|
+
try {
|
|
623
|
+
await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Update ${filePath} via Claude`]);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
|
|
626
|
+
return { pushed: false, reason: 'nothing to commit' };
|
|
627
|
+
}
|
|
628
|
+
throw e;
|
|
629
|
+
}
|
|
630
|
+
// write_file refuses on a push race rather than merging (conflict-refuse policy).
|
|
631
|
+
try {
|
|
632
|
+
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
633
|
+
} catch (e) {
|
|
634
|
+
const branch = await this._currentBranch();
|
|
635
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
636
|
+
await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
|
|
637
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
638
|
+
throw new Error(`${filePath}: Overleaf moved while writing; refused to overwrite. Re-read and retry. (${(e.stderr || e.message || '').slice(0, 120)})`);
|
|
639
|
+
}
|
|
640
|
+
return { pushed: true };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Upload binary file(s) from local disk into the clone and push. Single mode:
|
|
644
|
+
// {srcPath, destPath}. Batch mode: {files:[{srcPath, destPath}, ...]} -> one
|
|
645
|
+
// commit for the set. The whole set is gated BEFORE any copy (all-or-nothing).
|
|
646
|
+
// Binary never 3-way-merges, so a push race refuses + resets like writeFile.
|
|
647
|
+
// baseSha freshness applies in single mode only; existing files in batch mode
|
|
648
|
+
// require overwrite:true.
|
|
649
|
+
async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage } = {}) {
|
|
650
|
+
let pairs;
|
|
651
|
+
const batch = Array.isArray(files);
|
|
652
|
+
if (batch) {
|
|
653
|
+
if (srcPath || destPath) throw new Error('Pass either srcPath+destPath OR files[], not both.');
|
|
654
|
+
if (!files.length) throw new Error('files[] is empty.');
|
|
655
|
+
pairs = files.map(f => ({ src: f.srcPath, dest: f.destPath }));
|
|
656
|
+
} else if (srcPath && destPath) {
|
|
657
|
+
pairs = [{ src: srcPath, dest: destPath }];
|
|
658
|
+
} else {
|
|
659
|
+
throw new Error('upload_file needs srcPath+destPath (single) or files:[{srcPath,destPath}] (batch).');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await this.cloneOrPull();
|
|
663
|
+
const repoAbs = path.resolve(this.repoPath);
|
|
664
|
+
const resolved = [];
|
|
665
|
+
for (const { src, dest } of pairs) {
|
|
666
|
+
if (!src || !dest) throw new Error('each file needs srcPath and destPath.');
|
|
667
|
+
try { const s = await stat(src); if (!s.isFile()) throw new Error('not a file'); }
|
|
668
|
+
catch { throw new Error(`source file not found or unreadable: ${src}`); }
|
|
669
|
+
const destAbs = path.resolve(repoAbs, dest);
|
|
670
|
+
const rel = path.relative(repoAbs, destAbs);
|
|
671
|
+
if (destAbs === repoAbs || rel.startsWith('..') || path.isAbsolute(rel) || rel.split(path.sep)[0] === '.git') {
|
|
672
|
+
throw new Error(`destPath escapes the project or is not allowed: ${dest}`);
|
|
673
|
+
}
|
|
674
|
+
const current = await this.getBlobSha(rel, { pull: false });
|
|
675
|
+
if (current !== null) {
|
|
676
|
+
if (!batch && baseSha != null) {
|
|
677
|
+
if (baseSha !== current) {
|
|
678
|
+
throw new Error(`${rel} changed on Overleaf since baseSha ${String(baseSha).slice(0, 12)} (now ${current.slice(0, 12)}); stale. Re-read and retry.`);
|
|
679
|
+
}
|
|
680
|
+
} else if (!overwrite) {
|
|
681
|
+
throw new Error(`${rel} already exists. Pass baseSha (single mode) or overwrite:true to replace it.`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
resolved.push({ src, destAbs, rel });
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
for (const { src, destAbs } of resolved) {
|
|
688
|
+
await mkdir(path.dirname(destAbs), { recursive: true });
|
|
689
|
+
await copyFile(src, destAbs);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
693
|
+
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
694
|
+
await this._git(['-C', this.repoPath, 'add', '--', ...resolved.map(r => r.rel)]);
|
|
695
|
+
try {
|
|
696
|
+
await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Upload ${resolved.length} file(s) via Claude`]);
|
|
697
|
+
} catch (e) {
|
|
698
|
+
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
|
|
699
|
+
return { pushed: false, reason: 'nothing to commit (identical to repo)', files: resolved.map(r => r.rel) };
|
|
700
|
+
}
|
|
701
|
+
throw e;
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
|
|
705
|
+
} catch (e) {
|
|
706
|
+
const branch = await this._currentBranch();
|
|
707
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
708
|
+
await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
|
|
709
|
+
await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
|
|
710
|
+
throw new Error(`Overleaf moved while uploading; refused. Re-read and retry. (${(e.stderr || e.message || '').slice(0, 120)})`);
|
|
711
|
+
}
|
|
712
|
+
return { pushed: true, files: resolved.map(r => r.rel) };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Anchored, conflict-safe edit. Pulls first (absorbing non-overlapping Overleaf
|
|
716
|
+
// edits), then replaces oldString. A missing anchor means the user changed that
|
|
717
|
+
// region (overlap) or the string was wrong -> refuse, nothing written.
|
|
718
|
+
async editFile(filePath, oldString, newString, replaceAll = false, commitMessage) {
|
|
719
|
+
await this.cloneOrPull();
|
|
720
|
+
const fullPath = path.join(this.repoPath, filePath);
|
|
721
|
+
let content;
|
|
722
|
+
try { content = await readFile(fullPath, 'utf-8'); }
|
|
723
|
+
catch { throw new Error(`File not found in project: ${filePath}`); }
|
|
724
|
+
|
|
725
|
+
const parts = content.split(oldString);
|
|
726
|
+
const count = parts.length - 1;
|
|
727
|
+
if (count === 0) {
|
|
728
|
+
throw new Error(`oldString not found in ${filePath}. It may have changed on Overleaf since you read it (an overlapping edit) — re-read the file and retry.`);
|
|
729
|
+
}
|
|
730
|
+
if (count > 1 && !replaceAll) {
|
|
731
|
+
throw new Error(`oldString matches ${count} times in ${filePath}; pass replaceAll: true or include more surrounding context to make it unique.`);
|
|
732
|
+
}
|
|
733
|
+
const updated = replaceAll ? parts.join(newString) : content.replace(oldString, () => newString);
|
|
734
|
+
await writeFile(fullPath, updated, 'utf-8');
|
|
735
|
+
|
|
736
|
+
await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
|
|
737
|
+
await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
|
|
738
|
+
await this._git(['-C', this.repoPath, 'add', '--', filePath]);
|
|
739
|
+
try {
|
|
740
|
+
await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Edit ${filePath} via Claude`]);
|
|
741
|
+
} catch (e) {
|
|
742
|
+
if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
|
|
743
|
+
return { pushed: false, reason: 'no change (new === old)' };
|
|
744
|
+
}
|
|
745
|
+
throw e;
|
|
746
|
+
}
|
|
747
|
+
return await this._pushWithMerge();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async getSectionContent(filePath, sectionTitle) {
|
|
751
|
+
const content = await this.readFile(filePath);
|
|
752
|
+
const sections = await this.getSections(filePath);
|
|
753
|
+
const target = sections.find(s => s.title === sectionTitle);
|
|
754
|
+
if (!target) {
|
|
755
|
+
throw new Error(`Section "${sectionTitle}" not found`);
|
|
756
|
+
}
|
|
757
|
+
// The body runs until the next heading of the SAME or HIGHER level, so a
|
|
758
|
+
// \section keeps its \subsections instead of being cut at the first one.
|
|
759
|
+
const rank = { section: 1, subsection: 2, subsubsection: 3 };
|
|
760
|
+
const next = sections.find(s => s.index > target.index && rank[s.type] <= rank[target.type]);
|
|
761
|
+
const endIdx = next ? next.index : content.length;
|
|
762
|
+
return content.substring(target.index, endIdx);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export { OverleafGitClient };
|
|
767
|
+
|
|
768
|
+
async function getClient(projectName) {
|
|
769
|
+
const config = await loadConfig();
|
|
770
|
+
const key = pickProjectKey(config, projectName);
|
|
771
|
+
const project = config.projects[key];
|
|
772
|
+
const gitToken = resolveGitToken(config, project);
|
|
773
|
+
const localPath = resolveLocalPath(config, key, project);
|
|
774
|
+
return { client: new OverleafGitClient(project.projectId, gitToken, localPath), key, project };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Parse an SSA name like "Y1 ABC123 SSA 5" → { year, courseCode, ssaNum, slug }.
|
|
778
|
+
function parseSsaName(name) {
|
|
779
|
+
const m = name.trim().match(/^Y(\d+)\s+([A-Za-z0-9]+)\s+SSA\s*(\d+)$/i);
|
|
780
|
+
if (!m) {
|
|
781
|
+
throw new Error(`SSA name must look like "Y<year> <COURSE_CODE> SSA <num>" (got "${name}")`);
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
year: parseInt(m[1], 10),
|
|
785
|
+
courseCode: m[2].toUpperCase(),
|
|
786
|
+
ssaNum: parseInt(m[3], 10),
|
|
787
|
+
slug: `y${m[1]}-${m[2].toLowerCase()}-ssa${m[3]}`,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Extract Overleaf project id from a full URL or accept a bare id.
|
|
792
|
+
function parseOverleafRef(ref) {
|
|
793
|
+
const trimmed = ref.trim();
|
|
794
|
+
const urlMatch = trimmed.match(/overleaf\.com\/project\/([a-f0-9]{16,32})/i);
|
|
795
|
+
if (urlMatch) return urlMatch[1];
|
|
796
|
+
if (/^[a-f0-9]{16,32}$/i.test(trimmed)) return trimmed;
|
|
797
|
+
throw new Error(`Could not extract Overleaf project id from "${ref}"`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Find the course folder matching <courseCode> under academicRoot/Year <year>/Q*.
|
|
801
|
+
async function findCourseFolder(academicRoot, year, courseCode) {
|
|
802
|
+
const yearDir = path.join(academicRoot, `Year ${year}`);
|
|
803
|
+
let quarters;
|
|
804
|
+
try {
|
|
805
|
+
quarters = await readdir(yearDir);
|
|
806
|
+
} catch {
|
|
807
|
+
throw new Error(`Year folder not found: ${yearDir}`);
|
|
808
|
+
}
|
|
809
|
+
const candidates = [];
|
|
810
|
+
for (const q of quarters) {
|
|
811
|
+
const qPath = path.join(yearDir, q);
|
|
812
|
+
let s;
|
|
813
|
+
try { s = await stat(qPath); } catch { continue; }
|
|
814
|
+
if (!s.isDirectory()) continue;
|
|
815
|
+
let entries;
|
|
816
|
+
try { entries = await readdir(qPath); } catch { continue; }
|
|
817
|
+
for (const e of entries) {
|
|
818
|
+
// Course folder convention: code is the first whitespace-delimited token of the folder name.
|
|
819
|
+
const firstToken = e.split(/[\s_-]/)[0].toUpperCase();
|
|
820
|
+
if (firstToken === courseCode.toUpperCase()) {
|
|
821
|
+
candidates.push({ quarter: q, folder: e, path: path.join(qPath, e) });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (candidates.length === 0) {
|
|
826
|
+
throw new Error(`No folder starting with "${courseCode}" found under any Q* of ${yearDir}`);
|
|
827
|
+
}
|
|
828
|
+
if (candidates.length > 1) {
|
|
829
|
+
throw new Error(`Ambiguous: multiple course folders match "${courseCode}":\n${candidates.map(c => ` ${c.path}`).join('\n')}`);
|
|
830
|
+
}
|
|
831
|
+
return candidates[0];
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Wipe a duplicated SSA's body content while preserving the preamble and structure.
|
|
835
|
+
// Returns { changed: [...], skipped: [...] }.
|
|
836
|
+
async function resetSsaContent(repoPath, { ssaName, author, date, readUrl, dryRun } = {}) {
|
|
837
|
+
const changed = [];
|
|
838
|
+
const skipped = [];
|
|
839
|
+
|
|
840
|
+
// 1. Empty chapter and appendix files matching the canonical layout.
|
|
841
|
+
const chaptersDir = path.join(repoPath, 'Chapters');
|
|
842
|
+
let chapterEntries = [];
|
|
843
|
+
try { chapterEntries = await readdir(chaptersDir); } catch { skipped.push('Chapters/ (missing)'); }
|
|
844
|
+
for (const f of chapterEntries) {
|
|
845
|
+
if (!/^(ch\d+|app_[A-Za-z0-9]+)\.tex$/i.test(f)) continue;
|
|
846
|
+
const p = path.join(chaptersDir, f);
|
|
847
|
+
if (!dryRun) await writeFile(p, '', 'utf-8');
|
|
848
|
+
changed.push(`Chapters/${f} (emptied)`);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// 2. Empty refs.bib if present.
|
|
852
|
+
const refsPath = path.join(repoPath, 'refs.bib');
|
|
853
|
+
try {
|
|
854
|
+
await access(refsPath);
|
|
855
|
+
if (!dryRun) await writeFile(refsPath, '', 'utf-8');
|
|
856
|
+
changed.push('refs.bib (emptied)');
|
|
857
|
+
} catch { skipped.push('refs.bib (missing)'); }
|
|
858
|
+
|
|
859
|
+
// 3. Wipe figures/ contents (keep the dir).
|
|
860
|
+
const figuresDir = path.join(repoPath, 'figures');
|
|
861
|
+
let figureEntries = [];
|
|
862
|
+
try { figureEntries = await readdir(figuresDir); } catch { skipped.push('figures/ (missing)'); }
|
|
863
|
+
for (const f of figureEntries) {
|
|
864
|
+
if (f === '.gitkeep') continue;
|
|
865
|
+
const p = path.join(figuresDir, f);
|
|
866
|
+
let s;
|
|
867
|
+
try { s = await stat(p); } catch { continue; }
|
|
868
|
+
if (s.isFile()) {
|
|
869
|
+
if (!dryRun) await rm(p, { force: true });
|
|
870
|
+
changed.push(`figures/${f} (removed)`);
|
|
871
|
+
} else if (s.isDirectory()) {
|
|
872
|
+
if (!dryRun) await rm(p, { recursive: true, force: true });
|
|
873
|
+
changed.push(`figures/${f}/ (removed)`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// 4. Rewrite title block in main.tex if any header field was provided.
|
|
878
|
+
if (ssaName || author || date || readUrl) {
|
|
879
|
+
const mainPath = path.join(repoPath, 'main.tex');
|
|
880
|
+
let main;
|
|
881
|
+
try { main = await readFile(mainPath, 'utf-8'); }
|
|
882
|
+
catch { skipped.push('main.tex (missing — header not rewritten)'); main = null; }
|
|
883
|
+
if (main) {
|
|
884
|
+
let updated = main;
|
|
885
|
+
// Match \title{...} including a \textbf{...} wrapper.
|
|
886
|
+
if (ssaName) {
|
|
887
|
+
updated = updated.replace(/\\title\{[^}]*?(?:\{[^}]*\}[^}]*?)*\}/, () => `\\title{\\textbf{${ssaName}}}`);
|
|
888
|
+
changed.push(`main.tex \\title{} → ${ssaName}`);
|
|
889
|
+
}
|
|
890
|
+
if (author) {
|
|
891
|
+
updated = updated.replace(/\\author\{[^}]*?(?:\{[^}]*\}[^}]*?)*\}/, () => `\\author{\\textbf{${author}}}`);
|
|
892
|
+
changed.push(`main.tex \\author{} → ${author}`);
|
|
893
|
+
}
|
|
894
|
+
if (date) {
|
|
895
|
+
updated = updated.replace(/\\date\{[^}]*?(?:\{[^}]*\}[^}]*?)*\}/, () => `\\date{\\textbf{${date}}}`);
|
|
896
|
+
changed.push(`main.tex \\date{} → ${date}`);
|
|
897
|
+
}
|
|
898
|
+
if (readUrl) {
|
|
899
|
+
// Replace any line that is exactly \url{...} (the read-link under \maketitle).
|
|
900
|
+
if (/\\url\{[^}]*\}/.test(updated)) {
|
|
901
|
+
updated = updated.replace(/\\url\{[^}]*\}/, `\\url{${readUrl}}`);
|
|
902
|
+
changed.push(`main.tex \\url{} → ${readUrl}`);
|
|
903
|
+
} else {
|
|
904
|
+
skipped.push('main.tex \\url{} (no existing \\url line found)');
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (!dryRun) await writeFile(mainPath, updated, 'utf-8');
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return { changed, skipped };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function readContext(projectKey, project) {
|
|
915
|
+
const mdPath = path.join(CONTEXTS_DIR, `${projectKey}.md`);
|
|
916
|
+
try {
|
|
917
|
+
await access(mdPath);
|
|
918
|
+
const md = await readFile(mdPath, 'utf-8');
|
|
919
|
+
return { source: mdPath, body: md.trim() };
|
|
920
|
+
} catch {
|
|
921
|
+
if (project?.context) {
|
|
922
|
+
return { source: '(inline in projects.json)', body: project.context };
|
|
923
|
+
}
|
|
924
|
+
return { source: '(none)', body: `(No context set. Create ${path.relative(DATA_HOME,mdPath)} or call update_context to add notes for this project.)` };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// MCP server
|
|
929
|
+
const server = new Server(
|
|
930
|
+
{ name: 'overleaf-forge', version: '2.7.1' },
|
|
931
|
+
{ capabilities: { tools: {} } }
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
935
|
+
tools: [
|
|
936
|
+
{
|
|
937
|
+
name: 'get_context',
|
|
938
|
+
description: 'Read writing guidelines + per-project context. Always call this at the start of any writing or editing session, and re-read whenever instructions feel forgotten. Both the guidelines and the project context md are re-read from disk on every call, so external edits take effect immediately without restarting.',
|
|
939
|
+
inputSchema: {
|
|
940
|
+
type: 'object',
|
|
941
|
+
properties: {
|
|
942
|
+
projectName: { type: 'string', description: 'Project key. Omit to auto-detect from current working directory.' },
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
name: 'list_projects',
|
|
948
|
+
description: 'List configured Overleaf projects (key, name, projectId, cwd, localPath). Shows which one auto-detects as default from the current CWD.',
|
|
949
|
+
inputSchema: { type: 'object', properties: {} },
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
name: 'bootstrap_ssa',
|
|
953
|
+
description: 'One-shot setup for a new SSA. Takes the Overleaf URL (or bare project id) and an SSA name in the format "Y<year> <COURSE_CODE> SSA <num>" (e.g. "Y1 ABC123 SSA 5"). Resolves the course folder under settings.academicRoot/Year <year>/Q*, creates settings.ssaSubdir/<ssaName>/ inside it, clones the Overleaf repo into a `overleaf/` subfolder, registers the project (key = slug), and scaffolds contexts/<slug>.md. If `cleanAfterClone` is true, also empties Chapters/ch*.tex, Chapters/app_*.tex, refs.bib, and figures/* and rewrites the title block in main.tex (use after duplicating a previous SSA in Overleaf). Returns a checklist of context questions; after the user answers, follow up with update_context to fill the context md.',
|
|
954
|
+
inputSchema: {
|
|
955
|
+
type: 'object',
|
|
956
|
+
properties: {
|
|
957
|
+
overleafRef: { type: 'string', description: 'Full Overleaf URL (https://www.overleaf.com/project/<id>) or just the project id.' },
|
|
958
|
+
ssaName: { type: 'string', description: 'SSA name in the form "Y<year> <COURSE_CODE> SSA <num>", e.g. "Y1 ABC123 SSA 5".' },
|
|
959
|
+
cleanAfterClone: { type: 'boolean', description: 'If true, run reset_ssa_content immediately after the clone (use when duplicating a previous SSA). Defaults to false.' },
|
|
960
|
+
readUrl: { type: 'string', description: 'Optional Overleaf read-only URL (https://www.overleaf.com/read/...) to inject under the title.' },
|
|
961
|
+
},
|
|
962
|
+
required: ['overleafRef', 'ssaName'],
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
name: 'reset_ssa_content',
|
|
967
|
+
description: 'Empty the body content of a duplicated SSA: clears Chapters/ch*.tex, Chapters/app_*.tex, refs.bib, and figures/* (keeps directories). Optionally rewrites \\title{}, \\author{}, \\date{}, and the standalone \\url{} line in main.tex. Commits and pushes. Run this AFTER duplicating a previous SSA project in the Overleaf web UI and cloning it locally, so you start from a clean slate without touching the preamble.',
|
|
968
|
+
inputSchema: {
|
|
969
|
+
type: 'object',
|
|
970
|
+
properties: {
|
|
971
|
+
projectName: { type: 'string', description: 'Project key. Defaults to the autodetected project.' },
|
|
972
|
+
ssaName: { type: 'string', description: 'Optional. If given, used to rewrite \\title{}. Recommended format: full assignment title, e.g. "SSA 5 for ABC123 Course Title".' },
|
|
973
|
+
author: { type: 'string', description: 'Optional. If given, rewrites \\author{}.' },
|
|
974
|
+
date: { type: 'string', description: 'Optional. If given, rewrites \\date{}.' },
|
|
975
|
+
readUrl: { type: 'string', description: 'Optional. If given, replaces the standalone \\url{...} line under \\maketitle.' },
|
|
976
|
+
dryRun: { type: 'boolean', description: 'If true, report what would change but do not write or push.' },
|
|
977
|
+
},
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
name: 'register_project',
|
|
982
|
+
description: 'Add or overwrite a project entry in projects.json. Lets you onboard a new Overleaf project without hand-editing JSON. Uses the global gitToken from settings. After registering, also call update_context to set the project notes.',
|
|
983
|
+
inputSchema: {
|
|
984
|
+
type: 'object',
|
|
985
|
+
properties: {
|
|
986
|
+
key: { type: 'string', description: 'Short identifier used to refer to this project (e.g. "ssa4", "thesis"). Becomes the filename of contexts/<key>.md.' },
|
|
987
|
+
projectId: { type: 'string', description: 'Overleaf project ID (from the URL https://www.overleaf.com/project/<ID>).' },
|
|
988
|
+
name: { type: 'string', description: 'Human-readable name. Defaults to the key if omitted.' },
|
|
989
|
+
cwd: { type: 'string', description: 'Local workspace directory where you run Claude for this project. Used for autodetection. Defaults to the current session CWD.' },
|
|
990
|
+
localPath: { type: 'string', description: 'Where to clone the Overleaf git repo. Defaults to settings.repoDir/<name>.' },
|
|
991
|
+
},
|
|
992
|
+
required: ['key', 'projectId'],
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
name: 'set_project_path',
|
|
997
|
+
description: 'Update the local clone path (localPath) and/or cwd for an existing project without hand-editing JSON.',
|
|
998
|
+
inputSchema: {
|
|
999
|
+
type: 'object',
|
|
1000
|
+
properties: {
|
|
1001
|
+
key: { type: 'string', description: 'Project key to update. Defaults to the autodetected project.' },
|
|
1002
|
+
localPath: { type: 'string', description: 'New local clone directory.' },
|
|
1003
|
+
cwd: { type: 'string', description: 'New workspace directory for autodetection.' },
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
name: 'update_context',
|
|
1009
|
+
description: 'Replace (or append to) the project context md at contexts/<key>.md. Use this to record assignment-specific notes, terminology, deadlines, structure constraints. Takes effect immediately — no restart needed.',
|
|
1010
|
+
inputSchema: {
|
|
1011
|
+
type: 'object',
|
|
1012
|
+
properties: {
|
|
1013
|
+
key: { type: 'string', description: 'Project key. Defaults to the autodetected project.' },
|
|
1014
|
+
content: { type: 'string', description: 'New context body (markdown).' },
|
|
1015
|
+
mode: { type: 'string', enum: ['replace', 'append'], description: 'replace (default) or append.' },
|
|
1016
|
+
},
|
|
1017
|
+
required: ['content'],
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
name: 'list_files',
|
|
1022
|
+
description: 'List files in the Overleaf project, filtered by extension.',
|
|
1023
|
+
inputSchema: {
|
|
1024
|
+
type: 'object',
|
|
1025
|
+
properties: {
|
|
1026
|
+
projectName: { type: 'string' },
|
|
1027
|
+
extension: { type: 'string', description: 'e.g. ".tex", ".bib". Defaults to ".tex".' },
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
name: 'read_file',
|
|
1033
|
+
description: 'Read a file from the Overleaf project. The first line of the result is an overleaf-mcp comment carrying the file baseSha (its git blob hash); pass that baseSha to write_file to detect and refuse a clobber of concurrent Overleaf edits.',
|
|
1034
|
+
inputSchema: {
|
|
1035
|
+
type: 'object',
|
|
1036
|
+
properties: {
|
|
1037
|
+
filePath: { type: 'string' },
|
|
1038
|
+
projectName: { type: 'string' },
|
|
1039
|
+
},
|
|
1040
|
+
required: ['filePath'],
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
name: 'get_sections',
|
|
1045
|
+
description: 'List \\section / \\subsection / \\subsubsection entries in a .tex file.',
|
|
1046
|
+
inputSchema: {
|
|
1047
|
+
type: 'object',
|
|
1048
|
+
properties: {
|
|
1049
|
+
filePath: { type: 'string' },
|
|
1050
|
+
projectName: { type: 'string' },
|
|
1051
|
+
},
|
|
1052
|
+
required: ['filePath'],
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
name: 'get_section_content',
|
|
1057
|
+
description: 'Get the body of a single section by title.',
|
|
1058
|
+
inputSchema: {
|
|
1059
|
+
type: 'object',
|
|
1060
|
+
properties: {
|
|
1061
|
+
filePath: { type: 'string' },
|
|
1062
|
+
sectionTitle: { type: 'string' },
|
|
1063
|
+
projectName: { type: 'string' },
|
|
1064
|
+
},
|
|
1065
|
+
required: ['filePath', 'sectionTitle'],
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
name: 'compile_file',
|
|
1070
|
+
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.',
|
|
1071
|
+
inputSchema: {
|
|
1072
|
+
type: 'object',
|
|
1073
|
+
properties: {
|
|
1074
|
+
filePath: { type: 'string' },
|
|
1075
|
+
engine: { type: 'string', description: 'pdflatex | xelatex | lualatex (default lualatex)' },
|
|
1076
|
+
projectName: { type: 'string' },
|
|
1077
|
+
},
|
|
1078
|
+
required: ['filePath'],
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
name: 'verify_build',
|
|
1083
|
+
description: 'Compile the entrypoint FROM SCRATCH (clean aux) 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. Reports page count; overfull/underfull boxes are warnings, not failures. Use as the final gate before declaring a writing task done.',
|
|
1084
|
+
inputSchema: {
|
|
1085
|
+
type: 'object',
|
|
1086
|
+
properties: {
|
|
1087
|
+
filePath: { type: 'string', description: 'The entrypoint, usually main.tex.' },
|
|
1088
|
+
engine: { type: 'string', description: 'pdflatex | xelatex | lualatex (default lualatex).' },
|
|
1089
|
+
projectName: { type: 'string' },
|
|
1090
|
+
},
|
|
1091
|
+
required: ['filePath'],
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
name: 'edit_file',
|
|
1096
|
+
description: 'Surgical, conflict-safe edit: replace oldString with newString in a file, then commit and push. 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). Non-overlapping concurrent edits auto-merge. oldString must match exactly once unless replaceAll is true. After editing, call compile_file to verify the build.',
|
|
1097
|
+
inputSchema: {
|
|
1098
|
+
type: 'object',
|
|
1099
|
+
properties: {
|
|
1100
|
+
filePath: { type: 'string' },
|
|
1101
|
+
oldString: { type: 'string', description: 'Exact text to replace. Include enough surrounding context to be unique.' },
|
|
1102
|
+
newString: { type: 'string', description: 'Replacement text.' },
|
|
1103
|
+
replaceAll: { type: 'boolean', description: 'Replace every occurrence (default false; otherwise oldString must be unique).' },
|
|
1104
|
+
commitMessage: { type: 'string' },
|
|
1105
|
+
projectName: { type: 'string' },
|
|
1106
|
+
},
|
|
1107
|
+
required: ['filePath', 'oldString', 'newString'],
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
name: 'write_file',
|
|
1112
|
+
description: 'Create a new file, or overwrite an existing one wholesale, then 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 writing, call compile_file to verify the build.',
|
|
1113
|
+
inputSchema: {
|
|
1114
|
+
type: 'object',
|
|
1115
|
+
properties: {
|
|
1116
|
+
filePath: { type: 'string' },
|
|
1117
|
+
content: { type: 'string' },
|
|
1118
|
+
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.' },
|
|
1119
|
+
overwrite: { type: 'boolean', description: 'Force-overwrite an existing file without a baseSha (deliberate full replacement). Ignored for new files.' },
|
|
1120
|
+
commitMessage: { type: 'string' },
|
|
1121
|
+
projectName: { type: 'string' },
|
|
1122
|
+
},
|
|
1123
|
+
required: ['filePath', 'content'],
|
|
1124
|
+
},
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
name: 'upload_file',
|
|
1128
|
+
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 compile_file.',
|
|
1129
|
+
inputSchema: {
|
|
1130
|
+
type: 'object',
|
|
1131
|
+
properties: {
|
|
1132
|
+
srcPath: { type: 'string', description: 'Absolute local path of the file to upload (single mode).' },
|
|
1133
|
+
destPath: { type: 'string', description: 'Path inside the Overleaf project, e.g. figures/fig1.png (single mode).' },
|
|
1134
|
+
files: {
|
|
1135
|
+
type: 'array',
|
|
1136
|
+
description: 'Batch mode: list of {srcPath, destPath}. One commit + push for the whole set. baseSha is ignored in batch mode.',
|
|
1137
|
+
items: { type: 'object', properties: { srcPath: { type: 'string' }, destPath: { type: 'string' } }, required: ['srcPath', 'destPath'] },
|
|
1138
|
+
},
|
|
1139
|
+
baseSha: { type: 'string', description: 'Single-file mode only: baseSha from read_file; a stale value is refused. Ignored in batch.' },
|
|
1140
|
+
overwrite: { type: 'boolean', description: 'Replace existing dest file(s). Required to overwrite in batch mode.' },
|
|
1141
|
+
commitMessage: { type: 'string' },
|
|
1142
|
+
projectName: { type: 'string' },
|
|
1143
|
+
},
|
|
1144
|
+
},
|
|
1145
|
+
},
|
|
1146
|
+
{
|
|
1147
|
+
name: 'search_text',
|
|
1148
|
+
description: 'Grep across the project\'s tracked files (find a \\label, a duplicate key, every \\autoref, etc.). Read-only. Regex by default; fixed:true for a literal string; ignoreCase:true for -i; extension (e.g. ".tex") restricts the file set. Returns file:line:match.',
|
|
1149
|
+
inputSchema: {
|
|
1150
|
+
type: 'object',
|
|
1151
|
+
properties: {
|
|
1152
|
+
query: { type: 'string', description: 'Pattern (POSIX extended regex unless fixed:true).' },
|
|
1153
|
+
fixed: { type: 'boolean', description: 'Treat query as a literal string (-F).' },
|
|
1154
|
+
ignoreCase: { type: 'boolean', description: 'Case-insensitive (-i).' },
|
|
1155
|
+
extension: { type: 'string', description: 'Restrict to files with this extension, e.g. ".tex".' },
|
|
1156
|
+
projectName: { type: 'string' },
|
|
1157
|
+
},
|
|
1158
|
+
required: ['query'],
|
|
1159
|
+
},
|
|
1160
|
+
},
|
|
1161
|
+
{
|
|
1162
|
+
name: 'add_citation',
|
|
1163
|
+
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.',
|
|
1164
|
+
inputSchema: {
|
|
1165
|
+
type: 'object',
|
|
1166
|
+
properties: {
|
|
1167
|
+
entry: { type: 'string', description: 'A complete BibTeX entry, e.g. @article{key, title={...}, ...}.' },
|
|
1168
|
+
commitMessage: { type: 'string' },
|
|
1169
|
+
projectName: { type: 'string' },
|
|
1170
|
+
},
|
|
1171
|
+
required: ['entry'],
|
|
1172
|
+
},
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
name: 'cite_lint',
|
|
1176
|
+
description: 'Report citation problems: undefined (\\cite keys with no refs.bib entry) and unused (refs.bib entries never cited). Read-only. Run before verify_build to catch undefined citations early.',
|
|
1177
|
+
inputSchema: { type: 'object', properties: { projectName: { type: 'string' } } },
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
name: 'checkpoint',
|
|
1181
|
+
description: 'Mark a local rollback point (a git tag mcp-snap/<label>) at the current state before a risky edit. label defaults to a timestamp. Local only (not pushed). Use restore to roll back.',
|
|
1182
|
+
inputSchema: { type: 'object', properties: { label: { type: 'string' }, projectName: { type: 'string' } } },
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
name: 'restore',
|
|
1186
|
+
description: 'Roll back to a checkpoint: re-applies the snapshot\'s file tree as a NEW commit on top of history and pushes (no force-push, no history rewrite). Overleaf reflects the rollback; intervening commits are preserved.',
|
|
1187
|
+
inputSchema: { type: 'object', properties: { label: { type: 'string' }, projectName: { type: 'string' } }, required: ['label'] },
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
name: 'voice_lint',
|
|
1191
|
+
description: 'Lint a .tex file with a user-configured prose linter (settings.voiceLinter in projects.json, or the OVERLEAF_VOICE_LINTER env var; no default). The command receives the file path and should exit non-zero on findings. Read-only and advisory: reports output, never blocks. Useful after editing prose via edit_file/write_file, which bypass any local editor hooks.',
|
|
1192
|
+
inputSchema: {
|
|
1193
|
+
type: 'object',
|
|
1194
|
+
properties: { filePath: { type: 'string' }, projectName: { type: 'string' } },
|
|
1195
|
+
required: ['filePath'],
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
name: 'status_summary',
|
|
1200
|
+
description: 'High-level project status: file count, main file, sections.',
|
|
1201
|
+
inputSchema: {
|
|
1202
|
+
type: 'object',
|
|
1203
|
+
properties: { projectName: { type: 'string' } },
|
|
1204
|
+
},
|
|
1205
|
+
},
|
|
1206
|
+
],
|
|
1207
|
+
}));
|
|
1208
|
+
|
|
1209
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1210
|
+
try {
|
|
1211
|
+
const { name, arguments: args } = request.params;
|
|
1212
|
+
|
|
1213
|
+
switch (name) {
|
|
1214
|
+
case 'list_projects': {
|
|
1215
|
+
const config = await loadConfig();
|
|
1216
|
+
const autodetected = (() => { try { return pickProjectKey(config); } catch { return null; } })();
|
|
1217
|
+
const projects = Object.entries(config.projects).map(([key, p]) => ({
|
|
1218
|
+
key,
|
|
1219
|
+
name: p.name,
|
|
1220
|
+
projectId: p.projectId,
|
|
1221
|
+
cwd: p.cwd || null,
|
|
1222
|
+
localPath: resolveLocalPath(config, key, p),
|
|
1223
|
+
autodetected: key === autodetected,
|
|
1224
|
+
}));
|
|
1225
|
+
return { content: [{ type: 'text', text: `Session CWD: ${SESSION_CWD}\n\n${JSON.stringify(projects, null, 2)}` }] };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
case 'bootstrap_ssa': {
|
|
1229
|
+
const config = await loadConfig();
|
|
1230
|
+
const academicRoot = config.settings?.academicRoot;
|
|
1231
|
+
if (!academicRoot) throw new Error('settings.academicRoot not set in projects.json');
|
|
1232
|
+
const ssaSubdir = config.settings?.ssaSubdir || 'MY SSAs';
|
|
1233
|
+
|
|
1234
|
+
const parsed = parseSsaName(args.ssaName);
|
|
1235
|
+
const projectId = parseOverleafRef(args.overleafRef);
|
|
1236
|
+
const course = await findCourseFolder(academicRoot, parsed.year, parsed.courseCode);
|
|
1237
|
+
|
|
1238
|
+
const workspaceDir = path.join(course.path, ssaSubdir, args.ssaName.trim());
|
|
1239
|
+
const cloneDir = path.join(workspaceDir, 'overleaf');
|
|
1240
|
+
await mkdir(workspaceDir, { recursive: true });
|
|
1241
|
+
|
|
1242
|
+
// Register the project so the MCP knows about it.
|
|
1243
|
+
const entry = {
|
|
1244
|
+
name: args.ssaName.trim(),
|
|
1245
|
+
projectId,
|
|
1246
|
+
cwd: workspaceDir,
|
|
1247
|
+
localPath: cloneDir,
|
|
1248
|
+
};
|
|
1249
|
+
config.projects[parsed.slug] = entry;
|
|
1250
|
+
await saveConfig(config);
|
|
1251
|
+
|
|
1252
|
+
// Trigger clone now so user immediately has files locally.
|
|
1253
|
+
const gitToken = resolveGitToken(config, entry);
|
|
1254
|
+
const client = new OverleafGitClient(projectId, gitToken, cloneDir);
|
|
1255
|
+
let cloneStatus;
|
|
1256
|
+
let cleanReport = null;
|
|
1257
|
+
try {
|
|
1258
|
+
await client.cloneOrPull();
|
|
1259
|
+
cloneStatus = `✓ Cloned ${projectId} → ${cloneDir}`;
|
|
1260
|
+
if (args.cleanAfterClone) {
|
|
1261
|
+
// Course folder convention: "<CODE> - <descriptor>" → descriptor for the title.
|
|
1262
|
+
const descriptor = course.folder.split(/\s*-\s*/).slice(1).join(' - ').trim();
|
|
1263
|
+
const fullTitle = descriptor
|
|
1264
|
+
? `SSA ${parsed.ssaNum} for ${parsed.courseCode} ${descriptor}`
|
|
1265
|
+
: `SSA ${parsed.ssaNum} for ${parsed.courseCode}`;
|
|
1266
|
+
const reset = await resetSsaContent(cloneDir, {
|
|
1267
|
+
ssaName: fullTitle,
|
|
1268
|
+
readUrl: args.readUrl,
|
|
1269
|
+
});
|
|
1270
|
+
// Stage + commit + push the wipe so Overleaf reflects it.
|
|
1271
|
+
try {
|
|
1272
|
+
const r = await client.commitAndPush(`Reset content for ${args.ssaName.trim()}`, { addAll: true });
|
|
1273
|
+
cleanReport = r.pushed ? reset : { ...reset, note: 'nothing to commit' };
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
cleanReport = { ...reset, note: `commit/push failed: ${e.message}` };
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
} catch (e) {
|
|
1279
|
+
cloneStatus = `✗ Clone failed: ${e.message}\n (project is still registered; fix the token or id and call list_files to retry)`;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Scaffold context md from the (user-configurable) context-scaffold
|
|
1283
|
+
// template, falling back to the built-in default if none is provided.
|
|
1284
|
+
await mkdir(CONTEXTS_DIR, { recursive: true });
|
|
1285
|
+
const ctxPath = path.join(CONTEXTS_DIR, `${parsed.slug}.md`);
|
|
1286
|
+
const ctxTemplate = await loadTemplate(config, 'context-scaffold.md');
|
|
1287
|
+
const scaffold = (ctxTemplate != null ? ctxTemplate : DEFAULT_CONTEXT_SCAFFOLD)
|
|
1288
|
+
.replaceAll('__SSA_NAME__', args.ssaName.trim());
|
|
1289
|
+
try { await access(ctxPath); }
|
|
1290
|
+
catch { await writeFile(ctxPath, scaffold, 'utf-8'); }
|
|
1291
|
+
|
|
1292
|
+
const checklist = [
|
|
1293
|
+
'',
|
|
1294
|
+
'## Next step — set up context',
|
|
1295
|
+
'',
|
|
1296
|
+
'Ask the user for the following, one block at a time, then call `update_context` (mode=replace) with the filled-in context md. Do not invent answers.',
|
|
1297
|
+
'',
|
|
1298
|
+
'1. Topic & scope (one paragraph + what is out of scope)',
|
|
1299
|
+
'2. Deadline and submission target (date, time, length limit if any)',
|
|
1300
|
+
'3. Collaborators and division of labour',
|
|
1301
|
+
'4. Pinned decisions to respect',
|
|
1302
|
+
'5. Source material (lecture notes, chapters, datasheets, papers)',
|
|
1303
|
+
'6. Required section structure (any deviations from the standard SSA layout)',
|
|
1304
|
+
'7. Known unknowns and anything else relevant',
|
|
1305
|
+
'',
|
|
1306
|
+
'After collecting, write the populated context md via `update_context` with `key: "' + parsed.slug + '"`.',
|
|
1307
|
+
].join('\n');
|
|
1308
|
+
|
|
1309
|
+
const lines = [
|
|
1310
|
+
`# Bootstrapped ${args.ssaName.trim()}`,
|
|
1311
|
+
``,
|
|
1312
|
+
`**Course folder:** ${course.path}`,
|
|
1313
|
+
`**Workspace:** ${workspaceDir}`,
|
|
1314
|
+
`**Overleaf clone:** ${cloneDir}`,
|
|
1315
|
+
`**Project key:** \`${parsed.slug}\``,
|
|
1316
|
+
`**Context md:** ${path.relative(DATA_HOME,ctxPath)}`,
|
|
1317
|
+
``,
|
|
1318
|
+
cloneStatus,
|
|
1319
|
+
];
|
|
1320
|
+
if (cleanReport) {
|
|
1321
|
+
lines.push('', '## Clean report');
|
|
1322
|
+
for (const c of cleanReport.changed) lines.push(`- ${c}`);
|
|
1323
|
+
for (const s of cleanReport.skipped) lines.push(`- (skipped) ${s}`);
|
|
1324
|
+
if (cleanReport.note) lines.push(`- note: ${cleanReport.note}`);
|
|
1325
|
+
}
|
|
1326
|
+
lines.push(checklist);
|
|
1327
|
+
|
|
1328
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
case 'reset_ssa_content': {
|
|
1332
|
+
const { client, project } = await getClient(args.projectName);
|
|
1333
|
+
await client.cloneOrPull();
|
|
1334
|
+
const report = await resetSsaContent(client.repoPath, {
|
|
1335
|
+
ssaName: args.ssaName,
|
|
1336
|
+
author: args.author,
|
|
1337
|
+
date: args.date,
|
|
1338
|
+
readUrl: args.readUrl,
|
|
1339
|
+
dryRun: args.dryRun,
|
|
1340
|
+
});
|
|
1341
|
+
let pushNote = '';
|
|
1342
|
+
if (!args.dryRun) {
|
|
1343
|
+
try {
|
|
1344
|
+
const r = await client.commitAndPush('Reset SSA content', { addAll: true });
|
|
1345
|
+
pushNote = r.pushed ? '✓ Pushed to Overleaf.' : '(nothing to commit — already clean)';
|
|
1346
|
+
} catch (e) {
|
|
1347
|
+
pushNote = `✗ commit/push failed: ${e.message}`;
|
|
1348
|
+
}
|
|
1349
|
+
} else {
|
|
1350
|
+
pushNote = '(dryRun — nothing written or pushed)';
|
|
1351
|
+
}
|
|
1352
|
+
const out = [
|
|
1353
|
+
`# Reset ${project.name}`,
|
|
1354
|
+
``,
|
|
1355
|
+
...report.changed.map(c => `- ${c}`),
|
|
1356
|
+
...report.skipped.map(s => `- (skipped) ${s}`),
|
|
1357
|
+
``,
|
|
1358
|
+
pushNote,
|
|
1359
|
+
];
|
|
1360
|
+
return { content: [{ type: 'text', text: out.join('\n') }] };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
case 'register_project': {
|
|
1364
|
+
const config = await loadConfig();
|
|
1365
|
+
if (!args.key || !args.projectId) throw new Error('key and projectId are required');
|
|
1366
|
+
const entry = {
|
|
1367
|
+
name: args.name || args.key,
|
|
1368
|
+
projectId: args.projectId,
|
|
1369
|
+
};
|
|
1370
|
+
if (args.cwd) entry.cwd = args.cwd;
|
|
1371
|
+
else if (SESSION_CWD) entry.cwd = SESSION_CWD;
|
|
1372
|
+
if (args.localPath) entry.localPath = args.localPath;
|
|
1373
|
+
config.projects[args.key] = entry;
|
|
1374
|
+
await saveConfig(config);
|
|
1375
|
+
await mkdir(CONTEXTS_DIR, { recursive: true });
|
|
1376
|
+
const ctxPath = path.join(CONTEXTS_DIR, `${args.key}.md`);
|
|
1377
|
+
try { await access(ctxPath); } catch {
|
|
1378
|
+
await writeFile(ctxPath, `# ${entry.name}\n\n(Context placeholder. Call update_context to fill this in, or edit ${path.relative(DATA_HOME,ctxPath)} directly.)\n`, 'utf-8');
|
|
1379
|
+
}
|
|
1380
|
+
return { content: [{ type: 'text', text: `Registered "${args.key}" → ${args.projectId}\ncwd: ${entry.cwd || '(none)'}\nlocalPath: ${resolveLocalPath(config, args.key, entry)}\ncontext: ${path.relative(DATA_HOME,ctxPath)}` }] };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
case 'set_project_path': {
|
|
1384
|
+
const config = await loadConfig();
|
|
1385
|
+
const key = args.key || pickProjectKey(config);
|
|
1386
|
+
const p = config.projects[key];
|
|
1387
|
+
if (!p) throw new Error(`Project "${key}" not found`);
|
|
1388
|
+
if (args.localPath) p.localPath = args.localPath;
|
|
1389
|
+
if (args.cwd) p.cwd = args.cwd;
|
|
1390
|
+
await saveConfig(config);
|
|
1391
|
+
return { content: [{ type: 'text', text: `Updated "${key}":\n cwd: ${p.cwd || '(unset)'}\n localPath: ${resolveLocalPath(config, key, p)}` }] };
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
case 'update_context': {
|
|
1395
|
+
const config = await loadConfig();
|
|
1396
|
+
const key = args.key || pickProjectKey(config);
|
|
1397
|
+
if (!config.projects[key]) throw new Error(`Project "${key}" not found`);
|
|
1398
|
+
await mkdir(CONTEXTS_DIR, { recursive: true });
|
|
1399
|
+
const ctxPath = path.join(CONTEXTS_DIR, `${key}.md`);
|
|
1400
|
+
const mode = args.mode || 'replace';
|
|
1401
|
+
let body = args.content;
|
|
1402
|
+
if (mode === 'append') {
|
|
1403
|
+
let existing = '';
|
|
1404
|
+
try { existing = await readFile(ctxPath, 'utf-8'); } catch { /* none */ }
|
|
1405
|
+
body = existing.replace(/\s+$/, '') + '\n\n' + args.content + '\n';
|
|
1406
|
+
} else if (!body.endsWith('\n')) {
|
|
1407
|
+
body += '\n';
|
|
1408
|
+
}
|
|
1409
|
+
await writeFile(ctxPath, body, 'utf-8');
|
|
1410
|
+
return { content: [{ type: 'text', text: `Wrote context for "${key}" → ${path.relative(DATA_HOME,ctxPath)} (${mode})` }] };
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
case 'get_context': {
|
|
1414
|
+
const config = await loadConfig();
|
|
1415
|
+
const key = pickProjectKey(config, args.projectName);
|
|
1416
|
+
const project = config.projects[key];
|
|
1417
|
+
let guidelines = '';
|
|
1418
|
+
try { guidelines = await readFile(GUIDELINES_PATH, 'utf-8'); }
|
|
1419
|
+
catch { guidelines = '(writing-guidelines.md missing from OverleafMCP folder)'; }
|
|
1420
|
+
const ctx = await readContext(key, project);
|
|
1421
|
+
const text = [
|
|
1422
|
+
`# Writing Context`,
|
|
1423
|
+
``,
|
|
1424
|
+
`**Active project:** \`${key}\` — ${project.name}`,
|
|
1425
|
+
`**Context source:** ${ctx.source}`,
|
|
1426
|
+
``,
|
|
1427
|
+
`---`,
|
|
1428
|
+
``,
|
|
1429
|
+
`## Global Guidelines`,
|
|
1430
|
+
``,
|
|
1431
|
+
guidelines.trim(),
|
|
1432
|
+
``,
|
|
1433
|
+
`---`,
|
|
1434
|
+
``,
|
|
1435
|
+
`## Project Context — ${project.name}`,
|
|
1436
|
+
``,
|
|
1437
|
+
ctx.body,
|
|
1438
|
+
].join('\n');
|
|
1439
|
+
return { content: [{ type: 'text', text }] };
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
case 'list_files': {
|
|
1443
|
+
const { client } = await getClient(args.projectName);
|
|
1444
|
+
const files = await client.listFiles(args.extension || '.tex');
|
|
1445
|
+
return { content: [{ type: 'text', text: files.join('\n') }] };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
case 'read_file': {
|
|
1449
|
+
const { client } = await getClient(args.projectName);
|
|
1450
|
+
const content = await client.readFile(args.filePath);
|
|
1451
|
+
const baseSha = await client.getBlobSha(args.filePath, { pull: false });
|
|
1452
|
+
const header = `<!-- overleaf-mcp baseSha: ${baseSha || 'none'} (pass as baseSha to write_file to guard against clobbering Overleaf edits) -->\n`;
|
|
1453
|
+
return { content: [{ type: 'text', text: header + content }] };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
case 'get_sections': {
|
|
1457
|
+
const { client } = await getClient(args.projectName);
|
|
1458
|
+
const sections = await client.getSections(args.filePath);
|
|
1459
|
+
return { content: [{ type: 'text', text: JSON.stringify(sections, null, 2) }] };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
case 'get_section_content': {
|
|
1463
|
+
const { client } = await getClient(args.projectName);
|
|
1464
|
+
const content = await client.getSectionContent(args.filePath, args.sectionTitle);
|
|
1465
|
+
return { content: [{ type: 'text', text: content }] };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
case 'compile_file': {
|
|
1469
|
+
const { client } = await getClient(args.projectName);
|
|
1470
|
+
const r = await client.compileFile(args.filePath, args.engine || 'lualatex');
|
|
1471
|
+
const status = r.pdfPath ? `✓ PDF written to ${r.pdfPath}` : '✗ Compilation failed — no PDF produced';
|
|
1472
|
+
const parts = [status];
|
|
1473
|
+
if (r.errors.length) parts.push(`\n--- Errors (${r.errors.length}) ---\n${r.errors.join('\n')}`);
|
|
1474
|
+
if (r.undefinedRefs.length) parts.push(`\n--- Undefined refs/citations (${r.undefinedRefs.length}) ---\n${r.undefinedRefs.join('\n')}`);
|
|
1475
|
+
if (r.overfull.length) parts.push(`\n--- Overfull/Underfull (${r.overfull.length}) ---\n${r.overfull.join('\n')}`);
|
|
1476
|
+
parts.push(`\n--- Log tail ---\n${r.tail}`);
|
|
1477
|
+
return { content: [{ type: 'text', text: parts.join('\n').trim() }] };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
case 'verify_build': {
|
|
1481
|
+
const { client } = await getClient(args.projectName);
|
|
1482
|
+
const v = await client.verifyBuild(args.filePath, args.engine || 'lualatex');
|
|
1483
|
+
if (v.pass) {
|
|
1484
|
+
const warn = (v.overfullCount || v.underfullCount)
|
|
1485
|
+
? ` (note: ${v.overfullCount} overfull / ${v.underfullCount} underfull boxes)` : '';
|
|
1486
|
+
return { content: [{ type: 'text', text: `✓ PASS — ${v.pageCount} pages${warn}` }] };
|
|
1487
|
+
}
|
|
1488
|
+
const parts = ['✗ FAIL'];
|
|
1489
|
+
if (!v.pdfProduced) parts.push('- no PDF produced');
|
|
1490
|
+
if (v.errors.length) parts.push(`- ${v.errors.length} error(s):\n${v.errors.slice(0, 20).join('\n')}`);
|
|
1491
|
+
if (v.undefinedRefs.length) parts.push(`- ${v.undefinedRefs.length} undefined reference(s):\n${v.undefinedRefs.slice(0, 20).join('\n')}`);
|
|
1492
|
+
if (v.undefinedCitations.length) parts.push(`- ${v.undefinedCitations.length} undefined citation(s):\n${v.undefinedCitations.slice(0, 20).join('\n')}`);
|
|
1493
|
+
if (v.overfullCount || v.underfullCount) parts.push(`- (warnings) ${v.overfullCount} overfull / ${v.underfullCount} underfull`);
|
|
1494
|
+
parts.push(`\n--- log tail ---\n${v.tail}`);
|
|
1495
|
+
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
case 'edit_file': {
|
|
1499
|
+
const { client } = await getClient(args.projectName);
|
|
1500
|
+
const res = await client.editFile(args.filePath, args.oldString, args.newString, args.replaceAll || false, args.commitMessage);
|
|
1501
|
+
const tail = res.pushed
|
|
1502
|
+
? `Edited ${args.filePath}${res.merged ? ' (auto-merged a concurrent Overleaf change)' : ''}. NEXT STEP: call compile_file on the project main .tex to verify the build.`
|
|
1503
|
+
: `No change applied to ${args.filePath} (${res.reason}).`;
|
|
1504
|
+
return { content: [{ type: 'text', text: tail }] };
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
case 'write_file': {
|
|
1508
|
+
const { client } = await getClient(args.projectName);
|
|
1509
|
+
const res = await client.writeFile(args.filePath, args.content, {
|
|
1510
|
+
baseSha: args.baseSha,
|
|
1511
|
+
overwrite: args.overwrite,
|
|
1512
|
+
commitMessage: args.commitMessage,
|
|
1513
|
+
});
|
|
1514
|
+
const tail = res.pushed
|
|
1515
|
+
? `Wrote ${args.filePath}. NEXT STEP: call compile_file on the project main .tex to verify the build.`
|
|
1516
|
+
: `No change detected for ${args.filePath} (${res.reason}).`;
|
|
1517
|
+
return { content: [{ type: 'text', text: tail }] };
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
case 'upload_file': {
|
|
1521
|
+
const { client } = await getClient(args.projectName);
|
|
1522
|
+
const res = await client.uploadFile({
|
|
1523
|
+
srcPath: args.srcPath,
|
|
1524
|
+
destPath: args.destPath,
|
|
1525
|
+
files: args.files,
|
|
1526
|
+
baseSha: args.baseSha,
|
|
1527
|
+
overwrite: args.overwrite,
|
|
1528
|
+
commitMessage: args.commitMessage,
|
|
1529
|
+
});
|
|
1530
|
+
const tail = res.pushed
|
|
1531
|
+
? `Uploaded ${res.files.length} file(s): ${res.files.join(', ')}. NEXT: reference each figure with \\includegraphics{...} via edit_file, then compile_file.`
|
|
1532
|
+
: `No upload performed (${res.reason}).`;
|
|
1533
|
+
return { content: [{ type: 'text', text: tail }] };
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
case 'search_text': {
|
|
1537
|
+
const { client } = await getClient(args.projectName);
|
|
1538
|
+
const r = await client.searchText({ query: args.query, fixed: args.fixed, ignoreCase: args.ignoreCase, extension: args.extension });
|
|
1539
|
+
if (!r.total) return { content: [{ type: 'text', text: `No matches for ${JSON.stringify(args.query)}.` }] };
|
|
1540
|
+
const more = r.total > r.matches.length ? `\n… (+${r.total - r.matches.length} more)` : '';
|
|
1541
|
+
return { content: [{ type: 'text', text: `${r.total} match(es):\n${r.matches.join('\n')}${more}` }] };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
case 'add_citation': {
|
|
1545
|
+
const { client } = await getClient(args.projectName);
|
|
1546
|
+
const res = await client.addCitation({ entry: args.entry, commitMessage: args.commitMessage });
|
|
1547
|
+
return { content: [{ type: 'text', text: res.pushed ? `Added citation "${res.key}" to refs.bib and pushed.` : `No change for "${res.key}" (${res.reason}).` }] };
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
case 'cite_lint': {
|
|
1551
|
+
const { client } = await getClient(args.projectName);
|
|
1552
|
+
const r = await client.citeLint();
|
|
1553
|
+
const parts = [`Citations: ${r.cited} cited, ${r.defined} defined.`];
|
|
1554
|
+
parts.push(r.undefined.length ? `✗ undefined (${r.undefined.length}): ${r.undefined.join(', ')}` : '✓ no undefined citations');
|
|
1555
|
+
parts.push(r.unused.length ? `unused (${r.unused.length}): ${r.unused.join(', ')}` : '✓ no unused entries');
|
|
1556
|
+
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
case 'checkpoint': {
|
|
1560
|
+
const { client } = await getClient(args.projectName);
|
|
1561
|
+
const r = await client.checkpoint(args.label);
|
|
1562
|
+
return { content: [{ type: 'text', text: `Checkpoint "${r.label}" at ${r.head.slice(0, 12)}. Roll back with restore("${r.label.replace('mcp-snap/', '')}").` }] };
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
case 'restore': {
|
|
1566
|
+
const { client } = await getClient(args.projectName);
|
|
1567
|
+
const r = await client.restore(args.label);
|
|
1568
|
+
return { content: [{ type: 'text', text: `Restored "${r.label}" and pushed (forward commit). Run compile_file/verify_build to confirm.` }] };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
case 'voice_lint': {
|
|
1572
|
+
const config = await loadConfig();
|
|
1573
|
+
const { client } = await getClient(args.projectName);
|
|
1574
|
+
const command = config.settings?.voiceLinter || process.env.OVERLEAF_VOICE_LINTER;
|
|
1575
|
+
const r = await client.voiceLint(args.filePath, { command });
|
|
1576
|
+
return { content: [{ type: 'text', text: r.clean ? `✓ voice OK — ${args.filePath}${r.findings ? `\n${r.findings}` : ''}` : `voice findings in ${args.filePath}:\n${r.findings}` }] };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
case 'status_summary': {
|
|
1580
|
+
const { client, key, project } = await getClient(args.projectName);
|
|
1581
|
+
const files = await client.listFiles();
|
|
1582
|
+
const mainFile = files.find(f => /(^|\/)main\.tex$/.test(f)) || files[0];
|
|
1583
|
+
let sections = [];
|
|
1584
|
+
if (mainFile) sections = await client.getSections(mainFile);
|
|
1585
|
+
return {
|
|
1586
|
+
content: [{
|
|
1587
|
+
type: 'text',
|
|
1588
|
+
text: JSON.stringify({
|
|
1589
|
+
activeProjectKey: key,
|
|
1590
|
+
activeProjectName: project.name,
|
|
1591
|
+
totalFiles: files.length,
|
|
1592
|
+
mainFile,
|
|
1593
|
+
totalSections: sections.length,
|
|
1594
|
+
files: files.slice(0, 20),
|
|
1595
|
+
}, null, 2),
|
|
1596
|
+
}],
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
default:
|
|
1601
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1602
|
+
}
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
// Defense in depth: scrub any tokenized URL that might surface in an error.
|
|
1605
|
+
const msg = String(error?.message ?? error).replace(/git:[^@\s/]+@/g, 'git:***@');
|
|
1606
|
+
return {
|
|
1607
|
+
content: [{ type: 'text', text: `Error: ${msg}` }],
|
|
1608
|
+
isError: true,
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
async function main() {
|
|
1614
|
+
const transport = new StdioServerTransport();
|
|
1615
|
+
await server.connect(transport);
|
|
1616
|
+
console.error(`overleaf-forge running on stdio (session cwd: ${SESSION_CWD})`);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// `overleaf-forge init`: scaffold the writable data home so a fresh install
|
|
1620
|
+
// has a projects.json to edit and an editable copy of the bundled templates.
|
|
1621
|
+
// Safe to re-run: never overwrites an existing config or template.
|
|
1622
|
+
async function runInit() {
|
|
1623
|
+
await mkdir(DATA_HOME, { recursive: true });
|
|
1624
|
+
await mkdir(CONTEXTS_DIR, { recursive: true });
|
|
1625
|
+
|
|
1626
|
+
if (existsSync(CONFIG_PATH)) {
|
|
1627
|
+
console.log(`Config already present: ${CONFIG_PATH} (left untouched)`);
|
|
1628
|
+
} else {
|
|
1629
|
+
let body = '{\n "settings": {},\n "projects": {}\n}\n';
|
|
1630
|
+
try { body = await readFile(path.join(PACKAGE_DIR, 'projects.example.json'), 'utf-8'); } catch {}
|
|
1631
|
+
await writeFile(CONFIG_PATH, body, 'utf-8');
|
|
1632
|
+
console.log(`Created ${CONFIG_PATH}`);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// Copy bundled templates the user hasn't already customised, so the scaffolds
|
|
1636
|
+
// are editable in place under the data home.
|
|
1637
|
+
const userTemplates = path.join(DATA_HOME, 'templates');
|
|
1638
|
+
await mkdir(userTemplates, { recursive: true });
|
|
1639
|
+
let bundled = [];
|
|
1640
|
+
try { bundled = await readdir(BUNDLED_TEMPLATES_DIR); } catch {}
|
|
1641
|
+
for (const name of bundled) {
|
|
1642
|
+
const dest = path.join(userTemplates, name);
|
|
1643
|
+
if (existsSync(dest)) continue;
|
|
1644
|
+
try { await copyFile(path.join(BUNDLED_TEMPLATES_DIR, name), dest); console.log(`Copied template ${name}`); } catch {}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
console.log(`\nData home: ${DATA_HOME}`);
|
|
1648
|
+
console.log('Next: put your Overleaf git token and project id in projects.json,');
|
|
1649
|
+
console.log('or skip the file and pass OVERLEAF_GIT_TOKEN + OVERLEAF_PROJECT_ID as env vars in your MCP client config.');
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// True when this file is the entry point. Both sides are realpath'd because npm
|
|
1653
|
+
// installs the bin as a symlink (how `npx` runs it): Node resolves import.meta.url
|
|
1654
|
+
// to the real target but leaves argv[1] as the symlink, so a raw string compare
|
|
1655
|
+
// would wrongly read as "imported" and never start the server.
|
|
1656
|
+
function isMainModule() {
|
|
1657
|
+
try {
|
|
1658
|
+
return Boolean(process.argv[1])
|
|
1659
|
+
&& realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
1660
|
+
} catch { return false; }
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Run directly (not imported by tests): dispatch the optional subcommand, else
|
|
1664
|
+
// start the stdio server.
|
|
1665
|
+
if (isMainModule()) {
|
|
1666
|
+
const onError = (error) => { console.error('Fatal error:', error); process.exit(1); };
|
|
1667
|
+
if (process.argv[2] === 'init') {
|
|
1668
|
+
runInit().catch(onError);
|
|
1669
|
+
} else {
|
|
1670
|
+
main().catch(onError);
|
|
1671
|
+
}
|
|
1672
|
+
}
|