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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project will be documented in this file.

## Unreleased

### Added
- `olcli project create <name>` creates a blank or example Overleaf project from the command line
- `OverleafClient.createProject()` exposes project creation through the package's programmatic API

## [0.10.0] - 2026-09-03

### Added
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Work with Overleaf projects directly from your command line. Edit locally with y
## Features

- 📋 **List** all your Overleaf projects
- ✨ **Create** blank or example projects
- ⬇️ **Pull** project files to local directory for offline editing
- ⬆️ **Push** local changes back to Overleaf
- 🔄 **Sync** bidirectionally with smart conflict detection
Expand Down Expand Up @@ -138,6 +139,7 @@ All commands auto-detect the project when run from a synced directory (contains
| `olcli download <file> [project]` | Download a single file |
| `olcli delete <file> [project]` | Delete a remote file or folder (alias: `rm`) |
| `olcli rename <old> <new> [project]` | Rename a remote file or folder (alias: `mv`) |
| `olcli project create <name>` | Create a blank or example project (`--template blank\|example`) |
| `olcli project rename <new> [project]` | Rename the project itself (`--dry-run`) |
| `olcli project rename-bulk` | Rename many projects by pattern (dry-run unless `--apply`) |
| `olcli compile [project]` | Trigger PDF compilation |
Expand Down Expand Up @@ -341,6 +343,7 @@ import { OverleafClient } from '@aloth/olcli';

const client = await OverleafClient.fromSessionCookie(cookie);

const created = await client.createProject('My Paper');
const projects = await client.listProjects();
const info = await client.getProjectInfo(projectId);
const zipBuf = await client.downloadProject(projectId);
Expand All @@ -357,7 +360,8 @@ const comments = await client.listComments(projectId, { status: 'open' });
import {
OverleafClient,
// Types
Project, ProjectInfo, FolderEntry, DocEntry, FileEntry,
Project, ProjectInfo, ProjectTemplate, CreateProjectOptions, CreatedProject,
FolderEntry, DocEntry, FileEntry,
CommentMessage, ProjectComment, CommentContext, CommentStatus,
ListCommentsOptions, AddCommentOptions, Credentials, SessionCookiePair,
// Config utilities
Expand Down
8 changes: 8 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ olcli pull "My Paper"
cd My_Paper/
```

### Create a project

```bash
olcli project create "My Paper"
olcli project create "Example Paper" --template example
```

### Edit and sync changes

```bash
Expand Down Expand Up @@ -253,6 +260,7 @@ zip arxiv.zip *.tex main.bbl figures/*.pdf
| `olcli logout` | Clear stored credentials |
| `olcli check` | Show config paths and credential sources |
| `olcli list` | List all projects |
| `olcli project create <name>` | Create a blank or example project |
| `olcli info [project]` | Show project details |
| `olcli pull [project] [dir]` | Download project files |
| `olcli push [dir]` | Upload local changes |
Expand Down
37 changes: 36 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@
}

console.log(chalk.dim(`Config saved to: ${getConfigPath()}`));
} catch (error: any) {

Check warning on line 223 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 223 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Authentication failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -243,7 +243,7 @@
const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName);
const projects = await client.listProjects();
spinner.succeed(`Authenticated with access to ${projects.length} projects`);
} catch (error: any) {

Check warning on line 246 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 246 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Session invalid: ${error.message}`);
}
});
Expand Down Expand Up @@ -295,7 +295,7 @@
console.log(` ${chalk.cyan(p.id)} - ${chalk.bold(p.name)}`);
console.log(` ${chalk.dim(`Last updated: ${date}`)}`);
}
} catch (error: any) {

Check warning on line 298 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 298 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand Down Expand Up @@ -337,7 +337,7 @@
}

setLastProject(proj.id);
} catch (error: any) {

Check warning on line 340 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 340 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -347,7 +347,7 @@
.command('comments')
.description('View and manage Overleaf comments');

function printCommentContext(comment: any): void {

Check warning on line 350 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 350 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
if (!comment.context) return;

const ctx = comment.context;
Expand Down Expand Up @@ -415,7 +415,7 @@
}

setLastProject(proj.id);
} catch (error: any) {

Check warning on line 418 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 418 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -439,7 +439,7 @@
}
spinner.succeed(`Replied to ${threadId}`);
setLastProject(proj.id);
} catch (error: any) {

Check warning on line 442 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 442 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -463,7 +463,7 @@
}
spinner.succeed(`Resolved ${threadId} at ${comment.path}:${comment.line}:${comment.column}`);
setLastProject(proj.id);
} catch (error: any) {

Check warning on line 466 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 466 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -487,7 +487,7 @@
}
spinner.succeed(`Reopened ${threadId} at ${comment.path}:${comment.line}:${comment.column}`);
setLastProject(proj.id);
} catch (error: any) {

Check warning on line 490 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 490 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -511,7 +511,7 @@
}
spinner.succeed(`Deleted ${threadId} from ${comment.path}:${comment.line}:${comment.column}`);
setLastProject(proj.id);
} catch (error: any) {

Check warning on line 514 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 514 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand Down Expand Up @@ -799,7 +799,42 @@

const projectCmd = program
.command('project')
.description('Operate on projects themselves (rename, bulk rename)');
.description('Operate on projects themselves (create, rename, bulk rename)');

projectCmd
.command('create <name>')
.description('Create a new project')
.option('-t, --template <type>', 'Project template: blank or example', 'blank')
.option('--json', 'Output as JSON')
.option('--cookie <session>', 'Session cookie override')
.action(async (name, options) => {
const template = options.template as string;
if (template !== 'blank' && template !== 'example') {
console.error(chalk.red(`Unsupported project template: ${template}`));
console.error('Supported templates: blank, example');
process.exit(1);
}

const spinner = ora('Creating project...').start();
try {
const client = await getClient(options.cookie);
const created = await client.createProject(name, { template });
setLastProject(created.id);

if (options.json) {
spinner.stop();
console.log(JSON.stringify(created, null, 2));
return;
}

spinner.succeed(`Created project: ${created.name}`);
console.log(` ${chalk.cyan(created.id)}`);
console.log(` ${chalk.cyan(created.url)}`);
} catch (error: any) {
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
});

projectCmd
.command('rename <newname> [project]')
Expand Down
56 changes: 56 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ export interface ProjectInfo {
rootFolder: FolderEntry[];
}

export type ProjectTemplate = 'blank' | 'example';

export interface CreateProjectOptions {
template?: ProjectTemplate;
}

export interface CreatedProject {
id: string;
name: string;
url: string;
ownerId?: string;
}

export interface FolderEntry {
_id: string;
name: string;
Expand Down Expand Up @@ -633,6 +646,49 @@ export class OverleafClient {
return projects.find(p => p.id === id);
}

/**
* Create a blank or example project.
*/
async createProject(name: string, options: CreateProjectOptions = {}): Promise<CreatedProject> {
const trimmed = name.trim();
if (!trimmed) {
throw new Error('Project name must not be empty');
}

const template = options.template ?? 'blank';
if (template !== 'blank' && template !== 'example') {
throw new Error(`Unsupported project template: ${template}`);
}

const response = await this.httpRequest(`${this.baseUrl}/project/new`, {
method: 'POST',
headers: this.getHeaders(true),
body: JSON.stringify({
projectName: trimmed,
...(template === 'example' ? { template } : {})
}),
expect: 'json'
});

if (!response.ok) {
throw new Error(`Failed to create project: ${response.status}`);
}

this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined);

const projectId = response.body?.project_id;
if (typeof projectId !== 'string' || !projectId) {
throw new Error('Failed to create project: response did not include a project ID');
}

return {
id: projectId,
name: trimmed,
url: `${this.baseUrl}/project/${projectId}`,
...(typeof response.body?.owner_ref === 'string' ? { ownerId: response.body.owner_ref } : {})
};
}

/**
* Get detailed project info including file tree
*/
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export {
// Interfaces
type Project,
type ProjectInfo,
type ProjectTemplate,
type CreateProjectOptions,
type CreatedProject,
type FolderEntry,
type DocEntry,
type FileEntry,
Expand Down
117 changes: 117 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { OverleafClient } from '../src/client.js';

interface CapturedRequest {
method?: string;
url?: string;
headers: IncomingMessage['headers'];
body: string;
}

async function withServer(
response: { status?: number; body: unknown; headers?: Record<string, string> },
run: (baseUrl: string, getRequest: () => CapturedRequest | undefined) => Promise<void>
): Promise<void> {
let request: CapturedRequest | undefined;
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
request = {
method: req.method,
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString('utf-8')
};
res.writeHead(response.status ?? 200, {
'Content-Type': 'application/json',
...response.headers
});
res.end(JSON.stringify(response.body));
});

await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
assert(address && typeof address === 'object');

try {
await run(`http://127.0.0.1:${address.port}`, () => request);
} finally {
await new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
}
}

test('createProject creates a blank project and maps the response', async () => {
await withServer(
{
body: { project_id: '0123456789abcdef01234567', owner_ref: 'owner-id' },
headers: { 'Set-Cookie': 'refreshed=session; Path=/' }
},
async (baseUrl, getRequest) => {
const client = new OverleafClient({
cookies: { session: 'secret' },
csrf: 'csrf-token',
baseUrl
});

const created = await client.createProject(' My Paper ');

assert.deepEqual(created, {
id: '0123456789abcdef01234567',
name: 'My Paper',
url: `${baseUrl}/project/0123456789abcdef01234567`,
ownerId: 'owner-id'
});
assert.equal(client.getCookie('refreshed'), 'session');
assert.deepEqual(JSON.parse(getRequest()?.body ?? ''), { projectName: 'My Paper' });
assert.equal(getRequest()?.method, 'POST');
assert.equal(getRequest()?.url, '/project/new');
assert.equal(getRequest()?.headers['content-type'], 'application/json');
assert.equal(getRequest()?.headers['x-csrf-token'], 'csrf-token');
}
);
});

test('createProject requests the example template', async () => {
await withServer(
{ body: { project_id: '0123456789abcdef01234567' } },
async (baseUrl, getRequest) => {
const client = new OverleafClient({ cookies: {}, csrf: 'csrf-token', baseUrl });
await client.createProject('Example Paper', { template: 'example' });
assert.deepEqual(JSON.parse(getRequest()?.body ?? ''), {
projectName: 'Example Paper',
template: 'example'
});
}
);
});

test('createProject rejects invalid input before making a request', async () => {
const client = new OverleafClient({ cookies: {}, csrf: 'csrf-token' });
await assert.rejects(client.createProject(' '), /Project name must not be empty/);
await assert.rejects(
client.createProject('Paper', { template: 'unsupported' as 'blank' }),
/Unsupported project template: unsupported/
);
});

test('createProject reports HTTP errors and malformed success responses', async () => {
await withServer(
{ status: 403, body: { message: 'forbidden' } },
async baseUrl => {
const client = new OverleafClient({ cookies: {}, csrf: 'csrf-token', baseUrl });
await assert.rejects(client.createProject('Paper'), /Failed to create project: 403/);
}
);

await withServer(
{ body: {} },
async baseUrl => {
const client = new OverleafClient({ cookies: {}, csrf: 'csrf-token', baseUrl });
await assert.rejects(client.createProject('Paper'), /response did not include a project ID/);
}
);
});
Loading