Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,15 @@ symbols); non-BMP/astral characters (emoji) are corrupted by Overleaf's own stor
parent_folder_id}`, `DELETE /project/<id>/{doc|file|folder}/<id>`, `POST .../<type>/<id>
/rename` — all need the session cookie and the `ol-csrfToken` from the project HTML.

**Uploading files** (images for `\includegraphics`) is HTTP: `POST /project/<id>/upload?folder_id=<id>`
with `multipart/form-data` (`relativePath=null`, `name`, `type`, `qqfile`) + cookie + CSRF →
`{success, entity_id, entity_type:"file"}`. A same-named file in the folder is replaced.
`ProjectSession.uploadFile` takes a local path or a Buffer, allows png/jpg/gif/svg/pdf/eps up to
50 MB, and registers the file in the local tree. For a local path the extension is checked on
the source file as well as the uploaded name, so `name` cannot relabel an arbitrary file (e.g. a
private key) as an image. `RestClient.request` sends `FormData` bodies without a `Content-Type`
(fetch adds the boundary).

**Compiling** is HTTP (CSRF + cookie), not realtime. `POST /project/<id>/compile?auto_compile=true`
with body `{rootDoc_id, draft, check:"silent", incrementalCompilesEnabled, stopOnFirstError}`
→ `{status, outputFiles:[{path,type,build,url}], clsiServerId, ...}`. `status` is
Expand Down Expand Up @@ -229,11 +238,12 @@ state; instead:
also `idempotentHint: false`, since each call kicks off a fresh build. The writes set
`destructiveHint` explicitly (it would otherwise default to `true` whenever `readOnlyHint`
is `false`): `true` for overwrite/remove tools (`replace_text`, `replace_range`,
`delete_range`, `delete_lines`, `set_document`, `delete_document`), `false` for the purely
additive ones (`insert_text`, `append_text`, `create_document`, `add_comment` — which only
annotates, leaving the text unchanged — and `rename_document`, which changes a name, losing
no content). The seven write tools that change document text also take an optional
`trackChanges` to submit as a review-mode suggestion.
`delete_range`, `delete_lines`, `set_document`, `delete_document`, and `upload_file` — which
replaces a same-named file), `false` for the purely additive ones (`insert_text`,
`append_text`, `create_document`, `add_comment` — which only annotates, leaving the text
unchanged — and `rename_document`, which changes a name, losing no content). The seven write
tools that change document text also take an optional `trackChanges` to submit as a
review-mode suggestion.

## Configuration (optional environment variables)

Expand All @@ -256,7 +266,7 @@ No configuration is required. Overrides (mainly for self-hosted / tuning):
the first argument and are `async`: `projectInfo`, `listDocuments`, `readDocument`, `getLines`,
`documentVersion`, `search`, `insertAt`, `insert`, `append`, `deleteRange`, `replaceRange`,
`replaceText`, `setText`, `deleteLines`, `setCursor`, `getConnectedUsers`, `createDocument`,
`createFolder`, `deleteDocument`, `renameDocument`, `resync`, `compile`. `ProjectSession`
`uploadFile`, `createFolder`, `deleteDocument`, `renameDocument`, `resync`, `compile`. `ProjectSession`
exposes the same methods without the leading `project` argument. Every edit method takes an
optional trailing `EditOptions` (`{trackChanges?}`) — set `trackChanges` to submit the edit as
a review-mode tracked change (see the track-changes note above). Comments add `addComment`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Bring Claude (and any agent) into Overleaf. Claudeleaf is a reliable TypeScript

- **Real editing, not git.** Edits go through Overleaf's live collaboration channel (ShareJS over Socket.IO), so they appear instantly to everyone in the project, attributed to your user.
- **All your projects, one instance.** List every project you can access and edit any of them by id or name — no project configured up front.
- **Full document control** — list documents, read text, insert at a position or line/column, replace ranges or literal text, delete ranges or lines, replace a whole document, and create/delete/rename files.
- **Full document control** — list documents, read text, insert at a position or line/column, replace ranges or literal text, delete ranges or lines, replace a whole document, create/delete/rename files, and **upload images** (e.g. a local W&B plot PNG) for `\includegraphics`: `claudeleaf upload <project> plot.png --folder figures` or the `overleaf_upload_file` MCP tool.
- **Review mode (track changes).** Submit any edit as a tracked change (a suggestion) with `trackChanges` instead of a direct edit, so a human accepts or rejects it in Overleaf. Requires track changes on the project (a premium feature).
- **Comments.** Anchor a comment to a span of text (like selecting text and commenting in the editor) and list a document's comments with their threads.
- **Compile & error logs** — trigger a LaTeX recompile and get back the status plus the parsed errors and warnings (with file and line), or the full raw log.
Expand Down
16 changes: 15 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { OverleafClient } from "./client.js";
import { Config } from "./config.js";
import { ClaudeleafError } from "./errors.js";

const VALUE_FLAGS = new Set(["count"]);
const VALUE_FLAGS = new Set(["count", "folder", "name"]);

function parseArgs(args: string[]): { positionals: string[]; flags: Record<string, string | boolean> } {
const positionals: string[] = [];
Expand Down Expand Up @@ -80,6 +80,8 @@ Usage: claudeleaf <command> [args]
comment <project> <path> <start> <end> [message|-]
comment on the [start,end) span
comments <project> <path> list a document's comments
upload <project> <localFile> [--folder DIR] [--name NAME]
upload an image/PDF for \includegraphics
compile <project> [--draft] [--stop-on-first-error] [--log] [--warnings]
mcp run the MCP server over stdio

Expand Down Expand Up @@ -229,6 +231,18 @@ async function run(argv: string[]): Promise<number> {
}
return 0;
}
case "upload": {
const file = positionals[1];
if (!file) {
console.error("error: 'upload' needs a local file path");
return 1;
}
const folder = typeof flags.folder === "string" ? flags.folder : undefined;
const name = typeof flags.name === "string" ? flags.name : undefined;
const res = await client.uploadFile(project, file, folder, name);
console.log(`Uploaded ${res.path} (id ${res._id ?? "?"})`);
return 0;
}
case "compile": {
const result = await client.compile(project, {
draft: boolFlag(flags.draft),
Expand Down
9 changes: 9 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,15 @@ export class OverleafClient {
return (await this.project(project)).createDocument(name, folder);
}

async uploadFile(
project: string,
source: string | Uint8Array,
folder?: string,
name?: string,
): Promise<{ _id?: string; path: string }> {
return (await this.project(project)).uploadFile(source, folder, name);
}

async createFolder(project: string, name: string, folder?: string): Promise<{ _id?: string }> {
return (await this.project(project)).createFolder(name, folder);
}
Expand Down
24 changes: 24 additions & 0 deletions src/mcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,30 @@ export function createServer(): McpServer {
},
);

server.registerTool(
"overleaf_upload_file",
{
description:
"Upload an image/figure (png, jpg, gif, svg, pdf, eps; max 50 MB) so LaTeX can \\includegraphics it. " +
"Give `path` (a local file path) or `base64` + `name`. `folder` defaults to the project root. " +
"A same-named file in the folder is replaced.",
inputSchema: {
project: z.string(),
path: z.string().optional(),
base64: z.string().optional(),
name: z.string().optional(),
folder: z.string().optional(),
},
annotations: { readOnlyHint: false, destructiveHint: true },
},
async ({ project, path, base64, name, folder }) => {
if (!path === !base64) throw new Error("provide exactly one of `path` or `base64`");
const source = path ?? Buffer.from(base64 as string, "base64");
const res = await getClient().uploadFile(project, source, folder, name);
return result({ ok: true, path: res.path, id: res._id });
},
);

server.registerTool(
"overleaf_delete_document",
{
Expand Down
37 changes: 34 additions & 3 deletions src/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,10 @@ export class RestClient {
private async request(
method: "GET" | "POST" | "DELETE",
path: string,
payload?: object,
payload?: object | FormData,
timeoutMs = this.config.requestTimeout * 1000,
): Promise<unknown> {
const isForm = payload instanceof FormData;
const url = `${this.config.baseUrl}${path}`;
let last: Response | undefined;
for (let attempt = 0; attempt < 4; attempt++) {
Expand All @@ -108,15 +109,21 @@ export class RestClient {
"User-Agent": this.config.userAgent,
Referer: this.config.projectUrl(this.projectId),
"X-CSRF-Token": await this.ensureCsrf(refresh),
"Content-Type": "application/json",
Accept: "application/json",
};
// For multipart bodies fetch sets Content-Type itself (it needs the boundary).
if (!isForm) headers["Content-Type"] = "application/json";
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined,
body:
method === "POST"
? isForm
? (payload as FormData)
: JSON.stringify(payload ?? {})
: undefined,
signal: AbortSignal.timeout(timeoutMs), // a stalled socket aborts and retries
});
} catch (e) {
Expand Down Expand Up @@ -153,6 +160,30 @@ export class RestClient {
}) as Promise<{ _id?: string }>;
}

/** Upload a binary file (image, PDF, ...) into a folder; replaces a same-named file. */
async uploadFile(
name: string,
parentFolderId: string,
data: Uint8Array,
mime: string,
): Promise<{ _id?: string }> {
const form = new FormData();
form.append("relativePath", "null");
form.append("name", name);
form.append("type", mime);
form.append("qqfile", new Blob([data], { type: mime }), name);
const res = (await this.request(
"POST",
`/project/${this.projectId}/upload?folder_id=${encodeURIComponent(parentFolderId)}`,
form,
Math.max(this.config.requestTimeout, 120) * 1000,
)) as { success?: boolean; entity_id?: string; error?: string };
if (res.success === false || !res.entity_id) {
throw new ClaudeleafError(`upload of ${name} failed: ${res.error ?? "no entity id returned"}`);
}
return { _id: res.entity_id };
}

createFolder(name: string, parentFolderId: string): Promise<{ _id?: string }> {
return this.request("POST", `/project/${this.projectId}/folder`, {
name,
Expand Down
56 changes: 56 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
*/

import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { basename, extname } from "node:path";

import { SessionManager } from "./auth.js";
import type { Config } from "./config.js";
Expand Down Expand Up @@ -395,6 +397,49 @@ export class ProjectSession {
return result;
}

/**
* Upload a binary file (e.g. an image for `\includegraphics`) into the project.
* `source` is a local file path or a Buffer (then `name` is required). An existing file
* with the same name in the folder is replaced.
*/
async uploadFile(
source: string | Uint8Array,
folder?: string,
name?: string,
): Promise<{ _id?: string; path: string }> {
const unsupported = (file: string) =>
new ClaudeleafError(
`unsupported file type for ${file} (allowed: ${Object.keys(UPLOAD_TYPES).join(", ")})`,
);
let data: Uint8Array;
let fileName = name;
if (typeof source === "string") {
// Check the local file's own extension too, not just the uploaded name: otherwise `name`
// could relabel any readable file (e.g. a private key) as an image and upload it.
if (!UPLOAD_TYPES[extname(source).toLowerCase()]) throw unsupported(source);
data = await readFile(source);
fileName ??= basename(source);
} else {
if (!fileName) throw new ClaudeleafError("a name is required when uploading raw bytes");
data = source;
}
if (/[\\/]/.test(fileName)) throw new ClaudeleafError(`invalid file name: ${fileName}`);
const mime = UPLOAD_TYPES[extname(fileName).toLowerCase()];
if (!mime) throw unsupported(fileName);
if (data.byteLength === 0) throw new ClaudeleafError(`${fileName} is empty`);
if (data.byteLength > MAX_UPLOAD_BYTES) {
throw new ClaudeleafError(`${fileName} exceeds the ${MAX_UPLOAD_BYTES / 1024 / 1024} MB upload limit`);
}
const folderId = this.folderId(folder);
const result = await this.rest.uploadFile(fileName, folderId, data, mime);
const path = joinPath(folder, fileName);
this.entities = this.entities.filter((e) => !(e.path === path && e.type === "file"));
if (result._id) {
this.entities.push({ id: result._id, name: fileName, path, type: "file", folderId });
}
return { ...result, path };
}

async createFolder(name: string, folder?: string): Promise<{ _id?: string }> {
return this.rest.createFolder(name, this.folderId(folder));
}
Expand Down Expand Up @@ -854,6 +899,17 @@ function validateRange(doc: Document, start: number, end: number): [number, numb
return [s, e];
}

const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
const UPLOAD_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
".eps": "application/postscript",
};

function joinPath(folder: string | undefined, name: string): string {
if (!folder || folder === "/" || folder === "") return name;
return folder.trim().replace(/^\/+|\/+$/g, "") + "/" + name;
Expand Down
64 changes: 64 additions & 0 deletions test/clientOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,67 @@ describe("account project resolution", () => {
await expect(resolve(client, "Dup")).rejects.toBeInstanceOf(ProjectNotFoundError);
});
});

describe("uploadFile", () => {
const PNG = Buffer.from("iVBORw0KGgo=", "base64");

function withFakeRest(session: ProjectSession) {
const calls: { name: string; folderId: string; size: number; mime: string }[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session as any).rest = {
uploadFile: async (name: string, folderId: string, data: Uint8Array, mime: string) => {
calls.push({ name, folderId, size: data.byteLength, mime });
return { _id: "file1" };
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session as any).rootFolderId = "root";
return calls;
}

it("uploads bytes, registers the file and replaces a same-named one", async () => {
const { session } = makeSession("x");
const calls = withFakeRest(session);
const res = await session.uploadFile(PNG, undefined, "plot.png");
expect(res.path).toBe("plot.png");
expect(calls).toEqual([{ name: "plot.png", folderId: "root", size: PNG.length, mime: "image/png" }]);
await session.uploadFile(PNG, undefined, "plot.png");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const files = (session as any).entities.filter((e: { type: string }) => e.type === "file");
expect(files).toHaveLength(1);
});

it("reads a local file and uses its basename", async () => {
const { session } = makeSession("x");
const calls = withFakeRest(session);
const { mkdtempSync, writeFileSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const file = join(mkdtempSync(join(tmpdir(), "cl-")), "wandb_loss.png");
writeFileSync(file, PNG);
const res = await session.uploadFile(file);
expect(res.path).toBe("wandb_loss.png");
expect(calls[0].mime).toBe("image/png");
});

it("refuses to relabel a non-image local file via `name`", async () => {
const { session } = makeSession("x");
const calls = withFakeRest(session);
const { mkdtempSync, writeFileSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const file = join(mkdtempSync(join(tmpdir(), "cl-")), "id_rsa");
writeFileSync(file, "-----BEGIN OPENSSH PRIVATE KEY-----\n");
await expect(session.uploadFile(file, undefined, "figure.png")).rejects.toThrow(/unsupported/);
expect(calls).toHaveLength(0);
});

it("rejects unsupported types, empty data, missing names and bad names", async () => {
const { session } = makeSession("x");
withFakeRest(session);
await expect(session.uploadFile(PNG, undefined, "a.exe")).rejects.toThrow(/unsupported/);
await expect(session.uploadFile(Buffer.alloc(0), undefined, "a.png")).rejects.toThrow(/empty/);
await expect(session.uploadFile(PNG)).rejects.toThrow(/name is required/);
await expect(session.uploadFile(PNG, undefined, "../a.png")).rejects.toThrow(/invalid file name/);
});
});
23 changes: 23 additions & 0 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,29 @@ describe.skipIf(!hasSession)("integration", () => {
}
});

// -- image upload --
it("uploads an image, lists it, uses it in a compile, and deletes it", async () => {
// 1x1 red PNG
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
);
const NAME = "claudeleaf_vitest.png";
const res = await client.uploadFile(project, png, undefined, NAME);
try {
expect(res._id).toBeTruthy();
const files = await client.listDocuments(project, true);
expect(files.some((f) => f.path === NAME)).toBe(true);
await client.append(project, SCRATCH, `\\includegraphics{${NAME}}\n`);
// re-upload replaces rather than duplicating
await client.uploadFile(project, png, undefined, NAME);
const again = await client.listDocuments(project, true);
expect(again.filter((f) => f.path === NAME)).toHaveLength(1);
} finally {
await client.deleteDocument(project, NAME).catch(() => {});
}
});

// -- compile --
it("compiles and returns status, log and output files", async () => {
const r = await client.compile(project);
Expand Down
Loading