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
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ 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. `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 @@ -196,7 +203,7 @@ state; instead:
`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`, and `rename_document` —
additive ones (`insert_text`, `append_text`, `create_document`, `upload_file`, and `rename_document` —
which changes a name, losing no content).

## Configuration (optional environment variables)
Expand All @@ -220,7 +227,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. Paths resolve by full path,
then id, then unique basename; `line`/`column` are 0-based and offsets are character indices
into the `\n`-joined text — **except** compile-log line numbers (`CompileResult.errors`/
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.
- **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.
- **Reliable by design** — automatic reconnect with document re-join, per-document edit serialization, and server-confirmed edits (no silent loss).
- **Zero config, easy sign-in.** No credentials stored and nothing to configure: run `claudeleaf login` once, sign in by hand in the browser that opens (handles reCAPTCHA / SSO / 2FA), and the session is cached.
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 @@ -68,6 +68,8 @@ Usage: claudeleaf <command> [args]
replace <project> <path> <old> <new> [--count N]
set <project> <path> replace whole document with stdin
search <project> <path> <query> find text
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 @@ -184,6 +186,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: Boolean(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 @@ -217,6 +217,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 @@ -186,6 +186,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: false },
},
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: "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
54 changes: 54 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
* in-flight edit).
*/

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

import { SessionManager } from "./auth.js";
import type { Config } from "./config.js";
import { Document } from "./document.js";
Expand Down Expand Up @@ -292,6 +295,46 @@ 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 }> {
let data: Uint8Array;
let fileName = name;
if (typeof source === "string") {
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 new ClaudeleafError(
`unsupported file type for ${fileName} (allowed: ${Object.keys(UPLOAD_TYPES).join(", ")})`,
);
}
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 @@ -731,6 +774,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
52 changes: 52 additions & 0 deletions test/clientOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,55 @@ 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("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