proton-drive-mcp 1.0.26 → 1.0.27

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.27 — 2026-08-11
4
+
5
+ A full audit against the Proton Drive CLI SDK source found that 9 existing tools called CLI commands or flags that don't exist, or had wrong semantics. This release fixes all of them and adds 7 new tools for public links, bulk access removal, album updates, and Photos timeline transfers.
6
+
7
+ ### Fixed (existing tools were broken — none of these worked correctly before)
8
+ - **`drive_auth_status`** — `auth status` doesn't exist in the CLI at all; every call errored. Now probes by resolving `/my-files` and treats a not-authenticated error as the signal. The `email` field is removed — the CLI has no way to query the signed-in account's email.
9
+ - **`drive_version`** — the CLI's `version` command ignores `--json` and always prints plain text; the tool was trying to JSON-parse it and always threw. Now parsed directly from stdout.
10
+ - **`drive_mkdir`** — the CLI has no `mkdir`; it's `filesystem create-folder <parentPath> <name>`. The tool now splits the given path into parent + name automatically.
11
+ - **`drive_move`** — the CLI's `move` only accepts a target *parent folder*; renaming is a separate `rename <path> <newName>` command. The tool still accepts a full destination path (unchanged external interface) but now composes `move`/`rename` correctly under the hood.
12
+ - **`drive_delete`** — passed a `--confirm` flag that doesn't exist, and the CLI only permits deleting items already inside `/trash` or `/photos-trash` (it rejects live paths). Flag removed; description corrected.
13
+ - **`drive_trash` / `drive_restore` / `drive_list_trash` / `drive_empty_trash`** — called a top-level `trash` command group that doesn't exist; trash operations are subcommands of `filesystem` (`filesystem trash`, `filesystem restore`, `filesystem empty-trash`), and listing trash is `filesystem list /trash`. `empty-trash` also had a nonexistent `--confirm` flag, removed.
14
+ - **`drive_share_revoke`** — called `sharing revoke --user`, which doesn't exist; the real command is `sharing remove --email`.
15
+ - **`drive_upload` / `drive_download`** — conflict strategy values were `skip`/`overwrite`/`rename`; the CLI's actual allowed values are `skip`/`replace`/`keep-both`/`merge` (`merge` is upload/folder-only). `drive_download` also gained a `conflictStrategy` parameter it never had.
16
+
17
+ ### Added
18
+ - **`drive_share_set_url`** — create or update a public share link (`sharing set-url`), with optional role, password, and expiration
19
+ - **`drive_share_remove_url`** — remove a public share link (`sharing remove-url`)
20
+ - **`drive_share_remove_all`** — remove every member and pending invitation from a shared path in one call (`sharing remove --everyone`)
21
+ - **`photos_update_album`** — rename an album or change its cover photo (`album update`)
22
+ - **`photos_list_timeline`** — list photos in your full Photos library timeline, not scoped to an album (`photo timeline`)
23
+ - **`photos_download`** — download one or more photos to a local folder (`photo download`)
24
+ - **`photos_upload`** — upload local photos/videos directly into your Photos library (`photo upload`)
25
+
26
+ ### Not implemented
27
+ - `sharing report` (abuse reporting) exists as source in the SDK but is not registered in the CLI's command registry — invoking it would fail with `CommandNotFoundError`. Excluded as dead code, not a gap.
28
+
3
29
  ## 1.0.25 — 2026-08-04
4
30
 
5
31
  ### Added
package/README.md CHANGED
@@ -30,7 +30,7 @@ Give Claude Desktop (or any MCP client) full access to your Proton Drive and Pro
30
30
 
31
31
  - **Claude manages your Proton Drive** — list, upload, download, move, share, trash, restore
32
32
  - **Proton Photos album management** — list albums, create/delete albums, add and remove photos
33
- - **Full CLI** — same 29 operations, scriptable and pipeable, works in cron and shell scripts
33
+ - **Full CLI** — same 36 operations, scriptable and pipeable, works in cron and shell scripts
34
34
  - **Zero credential exposure** — auth is handled entirely by the official Proton Drive CLI; this MCP never touches your password or session token
35
35
  - **Shell injection safe** — all CLI calls use `execFile` with discrete argument arrays, never string interpolation
36
36
  - **Privacy-native** — end-to-end encryption is handled by Proton's own CLI; this server is just a thin MCP wrapper
@@ -147,7 +147,7 @@ proton-drive-cli <command> [args]
147
147
  ### Auth & info
148
148
 
149
149
  ```bash
150
- proton-drive-cli auth status # check if authenticated
150
+ proton-drive-cli auth status # probes /my-files; the CLI has no dedicated status command
151
151
  proton-drive-cli auth logout # log out (clears OS keychain session)
152
152
  proton-drive-cli version # CLI and SDK version
153
153
  ```
@@ -161,13 +161,14 @@ proton-drive-cli list /my-files/Reports
161
161
  proton-drive-cli mkdir /my-files/NewFolder
162
162
 
163
163
  proton-drive-cli upload ./report.pdf /my-files/Reports
164
- proton-drive-cli upload ./dist /my-files/Releases --conflict overwrite
164
+ proton-drive-cli upload ./dist /my-files/Releases --conflict replace
165
165
 
166
166
  proton-drive-cli download /my-files/report.pdf ./local/report.pdf
167
- proton-drive-cli download /my-files/Reports ./local/Reports
167
+ proton-drive-cli download /my-files/Reports ./local/Reports --conflict keep-both
168
168
 
169
169
  proton-drive-cli move /my-files/old-name.pdf /my-files/new-name.pdf
170
- proton-drive-cli delete /my-files/obsolete-draft.pdf
170
+ proton-drive-cli copy /my-files/report.pdf /my-files/Archive
171
+ proton-drive-cli delete /trash/obsolete-draft.pdf --confirm # only works on items already in trash
171
172
 
172
173
  # Machine-readable output (pipe-friendly)
173
174
  proton-drive-cli list /my-files --json | jq '.[].name'
@@ -180,22 +181,39 @@ proton-drive-cli share status /my-files/Reports
180
181
  proton-drive-cli share invite /my-files/Reports alice@pm.me editor
181
182
  proton-drive-cli share invite /my-files/Reports bob@pm.me viewer --message "FYI"
182
183
  proton-drive-cli share revoke /my-files/Reports alice@pm.me
184
+ proton-drive-cli share remove-all /my-files/Reports --confirm # strip every member + pending invite
185
+
186
+ proton-drive-cli share set-url /my-files/Reports --role viewer --expiration 2026-06-06
187
+ proton-drive-cli share remove-url /my-files/Reports
183
188
  ```
184
189
 
185
190
  ### Trash
186
191
 
187
192
  ```bash
188
- proton-drive-cli trash /my-files/old-draft.pdf # move to trash
189
- proton-drive-cli trash list # see what's in trash
190
- proton-drive-cli restore /my-files/old-draft.pdf # restore from trash
191
- proton-drive-cli trash empty # permanently delete all trashed items
193
+ proton-drive-cli trash /my-files/old-draft.pdf # move to trash
194
+ proton-drive-cli trash list # see what's in trash
195
+ proton-drive-cli restore /my-files/old-draft.pdf # restore from trash
196
+ proton-drive-cli trash empty --confirm # permanently delete all trashed items
197
+ ```
198
+
199
+ ### Photos
200
+
201
+ ```bash
202
+ proton-drive-cli album list
203
+ proton-drive-cli album create "Summer 2026"
204
+ proton-drive-cli album update /albums/Summer2026 --name "Summer Trip"
205
+ proton-drive-cli album add-photo /albums/Summer2026 /photos/IMG_001.jpg
206
+
207
+ proton-drive-cli photo timeline
208
+ proton-drive-cli photo download /photos/IMG_001.jpg ./local/photos --conflict keep-both
209
+ proton-drive-cli photo upload ./camera-roll --conflict skip
192
210
  ```
193
211
 
194
212
  ### Pipe and script
195
213
 
196
214
  ```bash
197
215
  # Backup build output after CI
198
- proton-drive-cli upload ./dist /my-files/Releases/$(date +%Y-%m-%d) --conflict rename
216
+ proton-drive-cli upload ./dist /my-files/Releases/$(date +%Y-%m-%d) --conflict keep-both
199
217
 
200
218
  # Download all contracts for audit
201
219
  proton-drive-cli download /my-files/Contracts ./audit/contracts
@@ -218,7 +236,7 @@ proton-drive-cli share status /my-files/Projects
218
236
  `drive_list` · `drive_mkdir` · `drive_upload` · `drive_download` · `drive_move` · `drive_delete`
219
237
 
220
238
  ### Sharing
221
- `drive_share_status` · `drive_share_invite` · `drive_share_revoke`
239
+ `drive_share_status` · `drive_share_invite` · `drive_share_revoke` · `drive_share_remove_all` · `drive_share_set_url` · `drive_share_remove_url`
222
240
 
223
241
  ### Trash
224
242
  `drive_list_trash` · `drive_trash` · `drive_restore` · `drive_empty_trash`
@@ -233,7 +251,7 @@ proton-drive-cli share status /my-files/Projects
233
251
  `drive_list_invitations` · `drive_invitation_accept` · `drive_invitation_reject` · `drive_share_leave`
234
252
 
235
253
  ### Photos
236
- `photos_list_albums` · `photos_create_album` · `photos_delete_album` · `photos_list_album_photos` · `photos_add_to_album` · `photos_remove_from_album`
254
+ `photos_list_albums` · `photos_create_album` · `photos_update_album` · `photos_delete_album` · `photos_list_album_photos` · `photos_add_to_album` · `photos_remove_from_album` · `photos_list_timeline` · `photos_download` · `photos_upload`
237
255
 
238
256
  ---
239
257
 
@@ -241,35 +259,42 @@ proton-drive-cli share status /my-files/Projects
241
259
 
242
260
  | Tool | Description | Key parameters |
243
261
  |------|-------------|----------------|
244
- | `drive_auth_status` | Check if CLI is authenticated | — |
262
+ | `drive_auth_status` | Check if authenticated (probes `/my-files` — no native status command) | — |
245
263
  | `drive_auth_logout` | Log out (clear session) | — |
246
264
  | `drive_version` | CLI and SDK version info | — |
247
265
  | `drive_list` | List files and folders at a path | `path` |
248
266
  | `drive_mkdir` | Create a new empty folder | `path` |
249
- | `drive_upload` | Upload local file or folder | `localPath`, `remotePath`, `conflictStrategy` (skip/overwrite/rename) |
250
- | `drive_download` | Download to local path | `remotePath`, `localPath` |
251
- | `drive_move` | Move or rename | `sourcePath`, `destinationPath` |
252
- | `drive_delete` | Permanently delete ⚠️ | `path`, `confirmed: true` |
267
+ | `drive_upload` | Upload local file or folder | `localPath`, `remotePath`, `conflictStrategy` (skip/replace/keep-both/merge) |
268
+ | `drive_download` | Download to local path | `remotePath`, `localPath`, `conflictStrategy` (skip/replace/keep-both) |
269
+ | `drive_move` | Move and/or rename | `sourcePath`, `destinationPath` |
270
+ | `drive_copy` | Copy file or folder to another Drive location | `sourcePath`, `destinationPath` |
271
+ | `drive_delete` | Permanently delete an item already in trash ⚠️ | `path`, `confirmed: true` |
253
272
  | `drive_list_trash` | List items currently in trash | — |
254
273
  | `drive_share_status` | Get sharing members and URL | `path` |
255
274
  | `drive_share_invite` | Invite a user | `path`, `email`, `role` (viewer/editor/admin), `message?` |
256
- | `drive_share_revoke` | Revoke access | `path`, `email` |
275
+ | `drive_share_revoke` | Revoke one person's access | `path`, `email` |
276
+ | `drive_share_remove_all` | Remove every member + pending invitation at once ⚠️ | `path`, `confirmed: true` |
277
+ | `drive_share_set_url` | Create/update a public share link | `path`, `role?` (viewer/editor), `password?`, `expiration?` |
278
+ | `drive_share_remove_url` | Remove the public share link ⚠️ | `path` |
257
279
  | `drive_trash` | Move to trash | `path` |
258
280
  | `drive_restore` | Restore from trash | `path` |
259
281
  | `drive_empty_trash` | Permanently delete all trash ⚠️ | `confirmed: true` |
260
282
  | `drive_read_file` | Read text file from local sync folder | `path` |
261
283
  | `drive_write_file` | Write text file to local sync folder ⚠️ | `path`, `content` |
262
- | `drive_copy` | Copy file or folder to another Drive location | `sourcePath`, `destinationPath` |
263
284
  | `drive_list_invitations` | List pending sharing invitations received | — |
264
285
  | `drive_invitation_accept` | Accept a pending invitation | `uid` (from `drive_list_invitations`) |
265
286
  | `drive_invitation_reject` | Reject a pending invitation ⚠️ | `uid` (from `drive_list_invitations`) |
266
287
  | `drive_share_leave` | Leave a shared folder shared with you ⚠️ | `path` |
267
288
  | `photos_list_albums` | List all Proton Photos albums | — |
268
289
  | `photos_create_album` | Create a new empty album | `name` |
290
+ | `photos_update_album` | Rename an album or change its cover photo | `albumPath`, `name?`, `coverPhotoUid?` |
269
291
  | `photos_delete_album` | Delete an album ⚠️ | `albumPath`, `confirmed: true`, `force?`, `save?` |
270
292
  | `photos_list_album_photos` | List photos in an album | `albumPath` |
271
293
  | `photos_add_to_album` | Add a photo from your library to an album | `albumPath`, `photoPath` |
272
294
  | `photos_remove_from_album` | Remove a photo from an album (keeps it in library) ⚠️ | `albumPath`, `photoPath` |
295
+ | `photos_list_timeline` | List photos in your full library timeline | `loadDetails?` |
296
+ | `photos_download` | Download photos to a local folder | `photoPaths`, `localFolder`, `conflictStrategy?` (skip/replace/keep-both) |
297
+ | `photos_upload` | Upload local files directly into your Photos library | `localPaths`, `conflictStrategy?` (skip/keep-both) |
273
298
 
274
299
  > ⚠️ **Destructive tools** require `confirmed: true`. Use `drive_list_trash` first so you know what will be deleted, then pass `confirmed: true` to proceed.
275
300
 
@@ -291,9 +316,11 @@ proton-drive-cli share status /my-files/Projects
291
316
  ## Operational notes
292
317
 
293
318
  - `drive_upload` passes `--skip-thumbnails` by default. Remove it from the subprocess args if you want WebP preview generation (requires Bun 1.3.14+ installed).
294
- - `drive_move` and `drive_delete` are best-effort — the Proton Drive CLI documentation does not guarantee these commands exist in v1. If a command returns a CLI error, the MCP tool surfaces it cleanly.
319
+ - `drive_move` accepts a full destination path (parent + new name) for a familiar interface, but the underlying CLI only has separate `move` (change parent) and `rename` (change name) commands — this MCP translates automatically, issuing one or both as needed.
320
+ - `drive_delete` only works on items already in `/trash` or `/photos-trash` — the CLI rejects live paths. Trash an item first with `drive_trash`, or use `drive_empty_trash` to clear everything at once.
321
+ - `drive_auth_status` has no native CLI equivalent — it probes by resolving `/my-files` and reports authenticated based on whether that succeeds.
295
322
  - Paths are always Drive-absolute: `/my-files/folder/file.pdf`. Relative paths are not supported.
296
- - All calls include `--json` automatically — you never need to pass it manually via the CLI wrapper.
323
+ - All calls include `--json` automatically, except `drive_version`, whose underlying CLI command ignores `--json` and always prints plain text — this MCP parses it directly.
297
324
 
298
325
  ---
299
326
 
package/dist/cli.js CHANGED
@@ -4,25 +4,7 @@
4
4
  * Provides a human-friendly interface to the same DriveService used by the MCP server.
5
5
  *
6
6
  * Usage: proton-drive-cli <command> [args]
7
- * auth status
8
- * auth logout
9
- * version
10
- * list <remote-path>
11
- * mkdir <remote-path>
12
- * upload <local-path> <remote-path> [--conflict skip|overwrite|rename]
13
- * download <remote-path> <local-path>
14
- * move <src> <dst>
15
- * delete <path>
16
- * share status <path>
17
- * share invite <path> <email> <role> [--message "..."]
18
- * share revoke <path> <email>
19
- * trash <path>
20
- * trash list
21
- * trash empty
22
- * restore <path>
23
- *
24
- * Global flags:
25
- * --json Machine-readable JSON output
7
+ * Run with --help (or see usage() below) for the full command list.
26
8
  */
27
9
  import { DriveService } from "./services/drive.js";
28
10
  import { DriveCliError, DriveCliNotFoundError, DriveNotAuthenticatedError, DriveParseError } from "./utils/errors.js";
@@ -36,28 +18,40 @@ function usage() {
36
18
  proton-drive-cli — Proton Drive companion CLI
37
19
 
38
20
  Commands:
39
- auth status Check authentication status
21
+ auth status Check authentication status (probes /my-files)
40
22
  auth logout Log out
41
23
  version Show CLI/SDK version
42
24
  list <path> List files at path
43
25
  mkdir <path> Create a new folder
44
- upload <local> <remote> [--conflict X] Upload file/folder (skip/overwrite/rename)
45
- download <remote> <local> Download file/folder
26
+ upload <local> <remote> [--conflict X] Upload file/folder (skip/replace/keep-both/merge)
27
+ download <remote> <local> [--conflict X] Download file/folder (skip/replace/keep-both)
46
28
  move <src> <dst> Move/rename
47
- delete <path> Delete file/folder permanently
29
+ delete <path> --confirm Delete a file/folder already in trash, permanently
48
30
  share status <path> Show sharing info
49
31
  share invite <path> <email> <role> Invite user (viewer/editor/admin)
50
- share revoke <path> <email> Revoke access
32
+ share revoke <path> <email> Revoke one user's access
33
+ share remove-all <path> --confirm Remove everyone's access + pending invitations
34
+ share set-url <path> [--role] [--password] [--expiration] Create/update public link
35
+ share remove-url <path> Remove public link
36
+ share leave <path> Leave a folder shared with you
37
+ copy <src> <dst> Copy file/folder
51
38
  trash <path> Move to trash
52
39
  trash list List trash contents
53
- trash empty Permanently delete all trash
40
+ trash empty --confirm Permanently delete all trash
54
41
  restore <path> Restore from trash
42
+ invitation list List pending invitations
43
+ invitation accept <uid> Accept an invitation
44
+ invitation reject <uid> Reject an invitation
55
45
  album list List all photo albums
56
46
  album create <name> Create a new album
57
- album delete <path> [--force] [--save] Delete an album
47
+ album update <path> [--name] [--cover-photo-uid] Rename/update album cover
48
+ album delete <path> --confirm [--force] [--save] Delete an album
58
49
  album photos <path> List photos in an album
59
50
  album add-photo <album> <photo> Add a photo to an album
60
51
  album remove-photo <album> <photo> Remove a photo from an album
52
+ photo timeline [--load-details] List photos in your timeline
53
+ photo download <photo>... <local> [--conflict X] Download photos (skip/replace/keep-both)
54
+ photo upload <local>... [--conflict X] Upload photos to your library (skip/keep-both)
61
55
 
62
56
  Flags:
63
57
  --json Machine-readable JSON output (one line)
@@ -160,8 +154,8 @@ async function run() {
160
154
  }
161
155
  const remote = requirePath(rest[0], "upload <local> <remote>");
162
156
  const conflictRaw = getFlag("--conflict") ?? "skip";
163
- if (!["skip", "overwrite", "rename"].includes(conflictRaw)) {
164
- console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip, overwrite, or rename.`);
157
+ if (!["skip", "replace", "keep-both", "merge"].includes(conflictRaw)) {
158
+ console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip, replace, keep-both, or merge.`);
165
159
  process.exit(1);
166
160
  }
167
161
  const conflict = conflictRaw;
@@ -185,7 +179,12 @@ async function run() {
185
179
  process.exit(1);
186
180
  return;
187
181
  }
188
- print(await drive.download(remote, local));
182
+ const dConflictRaw = getFlag("--conflict") ?? "skip";
183
+ if (!["skip", "replace", "keep-both"].includes(dConflictRaw)) {
184
+ console.error(`Invalid --conflict value: ${dConflictRaw}. Must be skip, replace, or keep-both.`);
185
+ process.exit(1);
186
+ }
187
+ print(await drive.download(remote, local, dConflictRaw));
189
188
  break;
190
189
  }
191
190
  case "move": {
@@ -202,7 +201,7 @@ async function run() {
202
201
  case "delete": {
203
202
  const delPath = requirePath(sub, "delete <path> --confirm");
204
203
  if (!args.includes("--confirm")) {
205
- console.error(`This will permanently delete: ${delPath}\nPass --confirm to proceed.`);
204
+ console.error(`This permanently deletes an item already in trash: ${delPath}\nPass --confirm to proceed.`);
206
205
  process.exit(1);
207
206
  }
208
207
  await drive.delete(delPath);
@@ -241,6 +240,31 @@ async function run() {
241
240
  await drive.shareRevoke(revokePath, revokeEmail);
242
241
  console.log(`Revoked access for ${revokeEmail}.`);
243
242
  }
243
+ else if (sub === "remove-all") {
244
+ const removeAllPath = requirePath(rest[0], "share remove-all <path> --confirm");
245
+ if (!args.includes("--confirm")) {
246
+ console.error(`This removes everyone's access to: ${removeAllPath}\nPass --confirm to proceed.`);
247
+ process.exit(1);
248
+ }
249
+ await drive.shareRemove(removeAllPath, [], true);
250
+ console.log(`Removed all access to: ${removeAllPath}`);
251
+ }
252
+ else if (sub === "set-url") {
253
+ const setUrlPath = requirePath(rest[0], "share set-url <path> [--role] [--password] [--expiration]");
254
+ const roleRaw = getFlag("--role") ?? "viewer";
255
+ if (!["viewer", "editor"].includes(roleRaw)) {
256
+ console.error(`Invalid --role value: ${roleRaw}. Must be viewer or editor.`);
257
+ process.exit(1);
258
+ }
259
+ const password = getFlag("--password");
260
+ const expiration = getFlag("--expiration");
261
+ print(await drive.shareSetUrl(setUrlPath, roleRaw, password, expiration));
262
+ }
263
+ else if (sub === "remove-url") {
264
+ const removeUrlPath = requirePath(rest[0], "share remove-url <path>");
265
+ await drive.shareRemoveUrl(removeUrlPath);
266
+ console.log(`Public link removed: ${removeUrlPath}`);
267
+ }
244
268
  else if (sub === "leave") {
245
269
  const leavePath = requirePath(rest[0], "share leave <path>");
246
270
  await drive.shareLeave(leavePath);
@@ -323,6 +347,17 @@ async function run() {
323
347
  await drive.createAlbum(albumName);
324
348
  console.log(`Album created: ${albumName}`);
325
349
  }
350
+ else if (sub === "update") {
351
+ const albumPath = requirePath(rest[0], "album update <path> [--name] [--cover-photo-uid]");
352
+ const newName = getFlag("--name");
353
+ const coverPhotoUid = getFlag("--cover-photo-uid");
354
+ if (!newName && !coverPhotoUid) {
355
+ console.error("Usage: album update <path> [--name <name>] [--cover-photo-uid <uid>] (at least one required)");
356
+ process.exit(1);
357
+ }
358
+ await drive.updateAlbum(albumPath, newName, coverPhotoUid);
359
+ console.log(`Album updated: ${albumPath}`);
360
+ }
326
361
  else if (sub === "delete") {
327
362
  const albumPath = requirePath(rest[0], "album delete <path> [--force] [--save] --confirm");
328
363
  if (!args.includes("--confirm")) {
@@ -349,10 +384,72 @@ async function run() {
349
384
  console.log(`Removed ${photoPath} from ${albumPath}`);
350
385
  }
351
386
  else {
352
- console.error("Usage: album list | album create <name> | album delete <path> | album photos <path> | album add-photo <album> <photo> | album remove-photo <album> <photo>");
387
+ console.error("Usage: album list | album create <name> | album update <path> | album delete <path> | album photos <path> | album add-photo <album> <photo> | album remove-photo <album> <photo>");
353
388
  process.exit(1);
354
389
  }
355
390
  break;
391
+ case "photo": {
392
+ // Strip --conflict <value> out of a variadic positional list.
393
+ const conflictFlagIdx = rest.indexOf("--conflict");
394
+ const positionals = conflictFlagIdx === -1
395
+ ? rest
396
+ : [...rest.slice(0, conflictFlagIdx), ...rest.slice(conflictFlagIdx + 2)];
397
+ const conflictRaw = getFlag("--conflict") ?? "skip";
398
+ if (sub === "timeline") {
399
+ print(await drive.photoTimeline(args.includes("--load-details")));
400
+ }
401
+ else if (sub === "download") {
402
+ if (positionals.length < 2) {
403
+ console.error("Usage: photo download <photo>... <local-folder> [--conflict X]");
404
+ process.exit(1);
405
+ return;
406
+ }
407
+ const localFolder = positionals[positionals.length - 1];
408
+ const photoPaths = positionals.slice(0, -1).map((p) => requirePath(p, "photo download <photo>... <local-folder>"));
409
+ let localFolderValidated;
410
+ try {
411
+ localFolderValidated = validateLocalPath(localFolder);
412
+ }
413
+ catch (e) {
414
+ console.error(e instanceof Error ? e.message : String(e));
415
+ process.exit(1);
416
+ return;
417
+ }
418
+ if (!["skip", "replace", "keep-both"].includes(conflictRaw)) {
419
+ console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip, replace, or keep-both.`);
420
+ process.exit(1);
421
+ }
422
+ print(await drive.photoDownload(photoPaths, localFolderValidated, conflictRaw));
423
+ }
424
+ else if (sub === "upload") {
425
+ if (positionals.length === 0) {
426
+ console.error("Usage: photo upload <local>... [--conflict X]");
427
+ process.exit(1);
428
+ return;
429
+ }
430
+ const localPaths = [];
431
+ for (const p of positionals) {
432
+ try {
433
+ localPaths.push(validateLocalPath(p));
434
+ }
435
+ catch (e) {
436
+ console.error(e instanceof Error ? e.message : String(e));
437
+ process.exit(1);
438
+ return;
439
+ }
440
+ }
441
+ if (!["skip", "keep-both"].includes(conflictRaw)) {
442
+ console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip or keep-both.`);
443
+ process.exit(1);
444
+ }
445
+ print(await drive.photoUpload(localPaths, conflictRaw));
446
+ }
447
+ else {
448
+ console.error("Usage: photo timeline [--load-details] | photo download <photo>... <local> | photo upload <local>...");
449
+ process.exit(1);
450
+ }
451
+ break;
452
+ }
356
453
  default:
357
454
  console.error(`Unknown command: ${cmd}`);
358
455
  usage();
package/dist/index.js CHANGED
@@ -46,9 +46,9 @@ const TOOLS = [
46
46
  {
47
47
  name: "drive_auth_status",
48
48
  description: "Check whether the Proton Drive CLI has an active authenticated session. " +
49
- "Returns {authenticated: boolean, email?: string}. " +
49
+ "Returns {authenticated: boolean}. The underlying CLI has no dedicated status command — this probes by resolving /my-files, which makes a real (lightweight) call. " +
50
50
  "Use before any file operation when you need to confirm the session is valid — all other drive_* tools (except drive_version) require authentication. " +
51
- "Does not make a network call if the session token is already cached locally.",
51
+ "Does not expose the signed-in account's email — the CLI provides no way to query it.",
52
52
  annotations: { readOnlyHint: true, idempotentHint: true },
53
53
  inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
54
54
  },
@@ -96,7 +96,7 @@ const TOOLS = [
96
96
  description: "Upload a local file or folder to Proton Drive with end-to-end encryption. Requires authentication. " +
97
97
  "For folders, uploads recursively and preserves directory structure. " +
98
98
  "Returns {uploaded, skipped, failed} counts — fails the call if failed > 0 (common causes: quota exceeded, destination path not found, permission denied). " +
99
- "conflictStrategy defaults to 'skip' — only use 'overwrite' with explicit user confirmation since it permanently replaces the remote file. " +
99
+ "conflictStrategy defaults to 'skip'. Values: 'skip' (leave existing remote file unchanged), 'replace' (permanently overwrite — confirm with user first), 'keep-both' (upload with a unique name), 'merge' (folders only — merge contents instead of failing). " +
100
100
  "Do not use to move files already on Drive (use drive_move) or to write text content directly (use drive_write_file if PROTON_DRIVE_SYNC_PATH is set). " +
101
101
  "Ensure destination folder exists first with drive_list; create it with drive_mkdir if needed.",
102
102
  annotations: { openWorldHint: true },
@@ -113,10 +113,11 @@ const TOOLS = [
113
113
  },
114
114
  conflictStrategy: {
115
115
  type: "string",
116
- enum: ["skip", "overwrite", "rename"],
116
+ enum: ["skip", "replace", "keep-both", "merge"],
117
117
  description: "'skip' leaves existing remote files unchanged (default). " +
118
- "'overwrite' permanently replaces the remote file — confirm with user first. " +
119
- "'rename' uploads with a unique name to avoid conflicts.",
118
+ "'replace' permanently overwrites the remote file — confirm with user first. " +
119
+ "'keep-both' uploads with a unique name to avoid conflicts. " +
120
+ "'merge' merges folder contents instead of failing (folders only).",
120
121
  },
121
122
  },
122
123
  required: ["localPath", "remotePath"],
@@ -127,7 +128,7 @@ const TOOLS = [
127
128
  name: "drive_download",
128
129
  description: "Download a file or folder from Proton Drive to the local filesystem. Requires authentication. " +
129
130
  "For folders, downloads recursively. " +
130
- "Silently overwrites any existing local file at localPath — verify the destination before calling. " +
131
+ "conflictStrategy defaults to 'skip' — pass 'replace' to overwrite an existing local file, or 'keep-both' to save under a unique name. " +
131
132
  "Fails if the local parent directory does not exist. " +
132
133
  "Returns {downloaded} count. " +
133
134
  "Do not use to move files within Drive (use drive_move) or to read a small text file's contents (use drive_read_file if PROTON_DRIVE_SYNC_PATH is set).",
@@ -143,6 +144,13 @@ const TOOLS = [
143
144
  type: "string",
144
145
  description: "Absolute local destination path (must start with '/'). Parent directory must already exist.",
145
146
  },
147
+ conflictStrategy: {
148
+ type: "string",
149
+ enum: ["skip", "replace", "keep-both"],
150
+ description: "'skip' leaves an existing local file unchanged (default). " +
151
+ "'replace' overwrites it — confirm with user first. " +
152
+ "'keep-both' downloads under a unique name.",
153
+ },
146
154
  },
147
155
  required: ["remotePath", "localPath"],
148
156
  additionalProperties: false,
@@ -173,7 +181,7 @@ const TOOLS = [
173
181
  "To rename: keep the same parent, change only the filename (e.g. /my-files/old.pdf → /my-files/new.pdf). " +
174
182
  "To move: provide a different parent folder. " +
175
183
  "Fails if destinationPath is already occupied or if its parent folder does not exist. " +
176
- "Do not use to copy a file (no copy operation exists — upload again instead) or to download to local storage (use drive_download).",
184
+ "Do not use to copy a file while keeping the original (use drive_copy) or to download to local storage (use drive_download).",
177
185
  annotations: { destructiveHint: false },
178
186
  inputSchema: {
179
187
  type: "object",
@@ -193,9 +201,10 @@ const TOOLS = [
193
201
  },
194
202
  {
195
203
  name: "drive_delete",
196
- description: "Permanently delete a file or folder from Proton Drive — no trash step, no recovery possible. Requires authentication. " +
197
- "Requires confirmed=true; always show the exact path to the user and get explicit confirmation before calling. " +
198
- "Do not use when reversible deletion is acceptable — use drive_trash instead so the item can be recovered with drive_restore.",
204
+ description: "Permanently delete a file or folder that is already in the Proton Drive trash — irreversible. Requires authentication. " +
205
+ "The underlying CLI only allows permanent deletion of items already inside /trash or /photos-trash; it rejects live paths. " +
206
+ "Use drive_trash first to move a live item into trash, then pass its trash path here — or drive_empty_trash to clear everything at once. " +
207
+ "Requires confirmed=true; always show the exact path to the user and get explicit confirmation before calling.",
199
208
  annotations: { destructiveHint: true },
200
209
  inputSchema: {
201
210
  type: "object",
@@ -448,6 +457,80 @@ const TOOLS = [
448
457
  additionalProperties: false,
449
458
  },
450
459
  },
460
+ {
461
+ name: "drive_share_set_url",
462
+ description: "Create or update a public share link for a Proton Drive file or folder. Requires authentication. " +
463
+ "Anyone with the link can access the item at the given role — no invitation or Proton account required. " +
464
+ "Calling this again on the same path updates the existing link's role/password/expiration rather than creating a duplicate. " +
465
+ "Returns {url?, role?, expirationTime?} — the exact shape depends on the CLI/SDK response and fields may be absent. " +
466
+ "The password, if set, is passed as a CLI argument and will appear in shell history/process list on the machine running this server. " +
467
+ "Do not use for private sharing with specific people — use drive_share_invite instead.",
468
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
469
+ inputSchema: {
470
+ type: "object",
471
+ properties: {
472
+ path: {
473
+ type: "string",
474
+ description: "Absolute remote Drive path to create a public link for (must start with '/').",
475
+ },
476
+ role: {
477
+ type: "string",
478
+ enum: ["viewer", "editor"],
479
+ description: "Access level for anyone with the link. Defaults to 'viewer' if omitted.",
480
+ },
481
+ password: {
482
+ type: "string",
483
+ description: "Optional custom password required to access the link. Omit for no password.",
484
+ },
485
+ expiration: {
486
+ type: "string",
487
+ description: "Optional expiration date in ISO format, e.g. '2026-06-06'. Omit for no expiration.",
488
+ },
489
+ },
490
+ required: ["path"],
491
+ additionalProperties: false,
492
+ },
493
+ },
494
+ {
495
+ name: "drive_share_remove_url",
496
+ description: "Remove the public share link from a Proton Drive file or folder. Requires authentication. " +
497
+ "The link stops working immediately — direct member access (from drive_share_invite) is not affected. " +
498
+ "Do not use to remove a specific person's access — use drive_share_revoke instead.",
499
+ annotations: { destructiveHint: true },
500
+ inputSchema: {
501
+ type: "object",
502
+ properties: {
503
+ path: {
504
+ type: "string",
505
+ description: "Absolute remote Drive path whose public link should be removed (must start with '/').",
506
+ },
507
+ },
508
+ required: ["path"],
509
+ additionalProperties: false,
510
+ },
511
+ },
512
+ {
513
+ name: "drive_share_remove_all",
514
+ description: "Remove access for every member and every pending invitation (Proton and non-Proton) on a shared Proton Drive path, in a single call. Requires authentication and confirmed=true. " +
515
+ "Use drive_share_status first to show the user who currently has access. " +
516
+ "For removing one specific person, use drive_share_revoke instead — it is cheaper and less error-prone.",
517
+ annotations: { destructiveHint: true },
518
+ inputSchema: {
519
+ type: "object",
520
+ properties: {
521
+ path: {
522
+ type: "string",
523
+ description: "Absolute remote Drive path to strip all sharing access from (must start with '/').",
524
+ },
525
+ confirmed: {
526
+ type: "boolean",
527
+ description: "Must be true. Confirms the user has acknowledged this removes everyone's access at once.",
528
+ },
529
+ },
530
+ required: ["path", "confirmed"],
531
+ additionalProperties: false,
532
+ },
533
+ },
451
534
  // Photos / Albums
452
535
  {
453
536
  name: "photos_list_albums",
@@ -476,6 +559,32 @@ const TOOLS = [
476
559
  additionalProperties: false,
477
560
  },
478
561
  },
562
+ {
563
+ name: "photos_update_album",
564
+ description: "Rename an album or change its cover photo in Proton Photos. Requires authentication. " +
565
+ "At least one of name or coverPhotoUid must be provided. " +
566
+ "Use photos_list_album_photos to find a nodeUid to set as the cover.",
567
+ annotations: { destructiveHint: false },
568
+ inputSchema: {
569
+ type: "object",
570
+ properties: {
571
+ albumPath: {
572
+ type: "string",
573
+ description: "Absolute path of the album to update. Must start with /albums/. E.g. /albums/Vacation 2024",
574
+ },
575
+ name: {
576
+ type: "string",
577
+ description: "New name for the album. Omit to leave unchanged.",
578
+ },
579
+ coverPhotoUid: {
580
+ type: "string",
581
+ description: "nodeUid (from photos_list_album_photos) of the photo to set as the album cover. Omit to leave unchanged.",
582
+ },
583
+ },
584
+ required: ["albumPath"],
585
+ additionalProperties: false,
586
+ },
587
+ },
479
588
  {
480
589
  name: "photos_delete_album",
481
590
  description: "Delete a Proton Photos album. Requires authentication and confirmed=true. " +
@@ -572,6 +681,78 @@ const TOOLS = [
572
681
  additionalProperties: false,
573
682
  },
574
683
  },
684
+ {
685
+ name: "photos_list_timeline",
686
+ description: "List photos in your Proton Photos timeline (your full photo library, not scoped to an album). Requires authentication. " +
687
+ "Returns [{nodeUid}] by default — pass loadDetails=true for full node metadata (slower; buffers the whole list in memory first). " +
688
+ "Use photos_download to download items by path, or photos_add_to_album to add them to an album.",
689
+ annotations: { readOnlyHint: true, idempotentHint: true },
690
+ inputSchema: {
691
+ type: "object",
692
+ properties: {
693
+ loadDetails: {
694
+ type: "boolean",
695
+ description: "If true, fetch full node metadata for each photo instead of just its nodeUid. Default false.",
696
+ },
697
+ },
698
+ required: [],
699
+ additionalProperties: false,
700
+ },
701
+ },
702
+ {
703
+ name: "photos_download",
704
+ description: "Download one or more photos from Proton Photos (timeline, an album, or shared-with-me) to a local folder. Requires authentication. " +
705
+ "Multiple timeline photos can share the same filename — with conflictStrategy 'replace' or 'skip' only one copy survives locally; use 'keep-both' to keep all. " +
706
+ "Fails if any item fails to download. " +
707
+ "Do not use for regular Drive files — use drive_download instead.",
708
+ annotations: { openWorldHint: true },
709
+ inputSchema: {
710
+ type: "object",
711
+ properties: {
712
+ photoPaths: {
713
+ type: "array",
714
+ items: { type: "string" },
715
+ description: "One or more absolute photo paths to download (each must start with '/'). E.g. ['/photos/IMG_001.jpg']",
716
+ },
717
+ localFolder: {
718
+ type: "string",
719
+ description: "Absolute local destination folder (must start with '/'). Created if it does not exist.",
720
+ },
721
+ conflictStrategy: {
722
+ type: "string",
723
+ enum: ["skip", "replace", "keep-both"],
724
+ description: "How to handle local filename collisions. Defaults to 'skip'.",
725
+ },
726
+ },
727
+ required: ["photoPaths", "localFolder"],
728
+ additionalProperties: false,
729
+ },
730
+ },
731
+ {
732
+ name: "photos_upload",
733
+ description: "Upload one or more local photo or video files directly into your Proton Photos library (My Photos timeline). Requires authentication. " +
734
+ "Non-photo/video files are silently skipped. Folders are recursed but flattened into My Photos — folder structure is not preserved. " +
735
+ "Never overwrites — duplicates (matched by name + content hash) resolve to 'keep-both' or 'skip' only. " +
736
+ "Do not use for regular Drive files — use drive_upload instead.",
737
+ annotations: { openWorldHint: true },
738
+ inputSchema: {
739
+ type: "object",
740
+ properties: {
741
+ localPaths: {
742
+ type: "array",
743
+ items: { type: "string" },
744
+ description: "One or more absolute local paths to files or folders to upload (each must start with '/').",
745
+ },
746
+ conflictStrategy: {
747
+ type: "string",
748
+ enum: ["skip", "keep-both"],
749
+ description: "How to handle duplicate photos (matched by name + content hash). Defaults to 'skip'.",
750
+ },
751
+ },
752
+ required: ["localPaths"],
753
+ additionalProperties: false,
754
+ },
755
+ },
575
756
  // Sync-folder tools (requires PROTON_DRIVE_SYNC_PATH env var)
576
757
  {
577
758
  name: "drive_read_file",
@@ -660,8 +841,8 @@ export async function main() {
660
841
  }
661
842
  case "drive_upload": {
662
843
  const cs = typeof a.conflictStrategy === "string" ? a.conflictStrategy : "skip";
663
- if (!["skip", "overwrite", "rename"].includes(cs)) {
664
- return fail(`conflictStrategy must be skip, overwrite, or rename`);
844
+ if (!["skip", "replace", "keep-both", "merge"].includes(cs)) {
845
+ return fail(`conflictStrategy must be skip, replace, keep-both, or merge`);
665
846
  }
666
847
  const uploadResult = await drive.upload(validateLocalPath(a.localPath), validateRemotePath(a.remotePath), cs);
667
848
  if (uploadResult.failed > 0) {
@@ -669,8 +850,13 @@ export async function main() {
669
850
  }
670
851
  return ok(uploadResult);
671
852
  }
672
- case "drive_download":
673
- return ok(await drive.download(validateRemotePath(a.remotePath), validateLocalPath(a.localPath)));
853
+ case "drive_download": {
854
+ const dcs = typeof a.conflictStrategy === "string" ? a.conflictStrategy : "skip";
855
+ if (!["skip", "replace", "keep-both"].includes(dcs)) {
856
+ return fail(`conflictStrategy must be skip, replace, or keep-both`);
857
+ }
858
+ return ok(await drive.download(validateRemotePath(a.remotePath), validateLocalPath(a.localPath), dcs));
859
+ }
674
860
  case "drive_move": {
675
861
  const moveSrc = validateRemotePath(a.sourcePath);
676
862
  const moveDst = validateRemotePath(a.destinationPath);
@@ -750,6 +936,29 @@ export async function main() {
750
936
  await drive.shareLeave(leavePath);
751
937
  return ok({ message: `Left shared folder: ${leavePath}` });
752
938
  }
939
+ case "drive_share_set_url": {
940
+ const setUrlPath = validateRemotePath(a.path);
941
+ const role = typeof a.role === "string" ? a.role : "viewer";
942
+ if (!["viewer", "editor"].includes(role))
943
+ return fail("role must be viewer or editor");
944
+ const password = typeof a.password === "string" ? a.password : undefined;
945
+ const expiration = typeof a.expiration === "string" ? a.expiration : undefined;
946
+ const link = await drive.shareSetUrl(setUrlPath, role, password, expiration);
947
+ return ok(link);
948
+ }
949
+ case "drive_share_remove_url": {
950
+ const removeUrlPath = validateRemotePath(a.path);
951
+ await drive.shareRemoveUrl(removeUrlPath);
952
+ return ok({ message: `Public link removed: ${removeUrlPath}` });
953
+ }
954
+ case "drive_share_remove_all": {
955
+ if (a.confirmed !== true) {
956
+ return fail("drive_share_remove_all requires confirmed=true. Use drive_share_status first to show the user who has access.");
957
+ }
958
+ const removeAllPath = validateRemotePath(a.path);
959
+ await drive.shareRemove(removeAllPath, [], true);
960
+ return ok({ message: `Removed all access to: ${removeAllPath}` });
961
+ }
753
962
  case "photos_list_albums":
754
963
  return ok(await drive.listAlbums());
755
964
  case "photos_create_album": {
@@ -758,6 +967,17 @@ export async function main() {
758
967
  await drive.createAlbum(a.name.trim());
759
968
  return ok({ message: `Album created: ${a.name.trim()}` });
760
969
  }
970
+ case "photos_update_album": {
971
+ const updateAlbumPath = validateRemotePath(a.albumPath);
972
+ if (!updateAlbumPath.startsWith("/albums/"))
973
+ return fail("albumPath must start with /albums/");
974
+ const newName = typeof a.name === "string" && a.name.trim() ? a.name.trim() : undefined;
975
+ const coverPhotoUid = typeof a.coverPhotoUid === "string" && a.coverPhotoUid.trim() ? a.coverPhotoUid.trim() : undefined;
976
+ if (!newName && !coverPhotoUid)
977
+ return fail("At least one of name or coverPhotoUid must be provided");
978
+ await drive.updateAlbum(updateAlbumPath, newName, coverPhotoUid);
979
+ return ok({ message: `Album updated: ${updateAlbumPath}` });
980
+ }
761
981
  case "photos_delete_album": {
762
982
  if (a.confirmed !== true)
763
983
  return fail("photos_delete_album requires confirmed=true. Show the user the album name and photo count first.");
@@ -793,6 +1013,35 @@ export async function main() {
793
1013
  await drive.removePhotoFromAlbum(remAlbumPath, remPhotoPath);
794
1014
  return ok({ message: `Removed ${remPhotoPath} from ${remAlbumPath}` });
795
1015
  }
1016
+ case "photos_list_timeline":
1017
+ return ok(await drive.photoTimeline(a.loadDetails === true));
1018
+ case "photos_download": {
1019
+ if (!Array.isArray(a.photoPaths) || a.photoPaths.length === 0)
1020
+ return fail("photoPaths must be a non-empty array of strings");
1021
+ const downloadPaths = a.photoPaths.map((p) => validateRemotePath(p));
1022
+ const downloadFolder = validateLocalPath(a.localFolder);
1023
+ const pdcs = typeof a.conflictStrategy === "string" ? a.conflictStrategy : "skip";
1024
+ if (!["skip", "replace", "keep-both"].includes(pdcs))
1025
+ return fail("conflictStrategy must be skip, replace, or keep-both");
1026
+ const downloadSummary = await drive.photoDownload(downloadPaths, downloadFolder, pdcs);
1027
+ if (downloadSummary.failedItems > 0) {
1028
+ return fail(`Download completed with ${downloadSummary.failedItems} failed item(s). transferred=${downloadSummary.transferredItems}`);
1029
+ }
1030
+ return ok(downloadSummary);
1031
+ }
1032
+ case "photos_upload": {
1033
+ if (!Array.isArray(a.localPaths) || a.localPaths.length === 0)
1034
+ return fail("localPaths must be a non-empty array of strings");
1035
+ const uploadPaths = a.localPaths.map((p) => validateLocalPath(p));
1036
+ const pucs = typeof a.conflictStrategy === "string" ? a.conflictStrategy : "skip";
1037
+ if (!["skip", "keep-both"].includes(pucs))
1038
+ return fail("conflictStrategy must be skip or keep-both");
1039
+ const uploadSummary = await drive.photoUpload(uploadPaths, pucs);
1040
+ if (uploadSummary.failedItems > 0) {
1041
+ return fail(`Upload completed with ${uploadSummary.failedItems} failed item(s). transferred=${uploadSummary.transferredItems}`);
1042
+ }
1043
+ return ok(uploadSummary);
1044
+ }
796
1045
  case "drive_read_file": {
797
1046
  const syncRoot = getSyncRoot();
798
1047
  if (!syncRoot)
@@ -1,32 +1,44 @@
1
- import { runDrive as defaultRunDrive } from "../utils/subprocess.js";
2
- import { DriveParseError } from "../utils/errors.js";
1
+ import { posix as posixPath } from "node:path";
2
+ import { runDrive as defaultRunDrive, runDriveRaw as defaultRunDriveRaw } from "../utils/subprocess.js";
3
+ import { DriveNotAuthenticatedError, DriveParseError } from "../utils/errors.js";
3
4
  export class DriveService {
4
5
  run;
5
- constructor(runner) {
6
+ runRaw;
7
+ constructor(runner, rawRunner) {
6
8
  this.run = runner ?? defaultRunDrive;
9
+ this.runRaw = rawRunner ?? defaultRunDriveRaw;
7
10
  }
8
11
  // Auth
12
+ //
13
+ // The CLI has no `auth status` command (it doesn't exist). We probe by
14
+ // resolving a path every authenticated user has (/my-files) and treating
15
+ // a DriveNotAuthenticatedError as the signal. Any other error propagates —
16
+ // it means something unexpected happened, not that the session is invalid.
9
17
  async authStatus() {
10
- const result = await this.run(["auth", "status"]);
11
- if (result === null)
12
- return { authenticated: false };
13
- const r = result;
14
- return {
15
- authenticated: Boolean(r?.authenticated ?? r?.loggedIn ?? false),
16
- email: typeof r?.email === "string" ? r.email : undefined,
17
- };
18
+ try {
19
+ await this.run(["filesystem", "info", "/my-files"]);
20
+ return { authenticated: true };
21
+ }
22
+ catch (err) {
23
+ if (err instanceof DriveNotAuthenticatedError)
24
+ return { authenticated: false };
25
+ throw err;
26
+ }
18
27
  }
19
28
  async authLogout() {
20
29
  await this.run(["auth", "logout"]);
21
30
  }
31
+ // `version` ignores --json entirely and always prints plain text:
32
+ // Proton Drive CLI 1.2.3
33
+ // Proton Drive SDK 4.5.6
34
+ // ...update-check line...
22
35
  async version() {
23
- const result = await this.run(["version"]);
24
- if (result === null)
25
- throw new DriveParseError("version command returned no output");
26
- const r = result;
36
+ const text = await this.runRaw(["version"]);
37
+ const cliMatch = text.match(/Proton Drive CLI\s+(\S+)/);
38
+ const sdkMatch = text.match(/Proton Drive SDK\s+(\S+)/);
27
39
  return {
28
- cli: typeof r.cli === "string" ? r.cli : String(r.version ?? "unknown"),
29
- sdk: typeof r.sdk === "string" ? r.sdk : "unknown",
40
+ cli: cliMatch ? cliMatch[1] : "unknown",
41
+ sdk: sdkMatch ? sdkMatch[1] : "unknown",
30
42
  };
31
43
  }
32
44
  // Filesystem
@@ -63,8 +75,11 @@ export class DriveService {
63
75
  failed: typeof r?.failed === "number" ? r.failed : 0,
64
76
  };
65
77
  }
66
- async download(remotePath, localPath) {
67
- const result = await this.run(["filesystem", "download", remotePath, localPath]);
78
+ async download(remotePath, localPath, conflictStrategy = "skip") {
79
+ const result = await this.run([
80
+ "filesystem", "download", remotePath, localPath,
81
+ "--conflict-strategy", conflictStrategy,
82
+ ]);
68
83
  const r = (result ?? {});
69
84
  return {
70
85
  path: remotePath,
@@ -72,14 +87,41 @@ export class DriveService {
72
87
  downloaded: typeof r?.downloaded === "number" ? r.downloaded : 0,
73
88
  };
74
89
  }
90
+ // The CLI has no `mkdir` — it's `create-folder <parentPath> <name>`.
75
91
  async mkdir(remotePath) {
76
- await this.run(["filesystem", "mkdir", remotePath]);
92
+ const parent = posixPath.dirname(remotePath);
93
+ const name = posixPath.basename(remotePath);
94
+ if (!name || parent === remotePath) {
95
+ throw new Error(`path must include a folder name to create: ${remotePath}`);
96
+ }
97
+ await this.run(["filesystem", "create-folder", parent, name]);
77
98
  }
78
- async move(remoteSrc, remoteDst) {
79
- await this.run(["filesystem", "move", remoteSrc, remoteDst]);
99
+ // The CLI has no single "move to any full path" command — `move` only
100
+ // accepts a target *parent folder*, and renaming is a separate `rename`
101
+ // command. We keep the tool's external contract (a full destination path)
102
+ // by translating into the right combination of the two real commands.
103
+ async move(sourcePath, destinationPath) {
104
+ const srcParent = posixPath.dirname(sourcePath);
105
+ const srcName = posixPath.basename(sourcePath);
106
+ const dstParent = posixPath.dirname(destinationPath);
107
+ const dstName = posixPath.basename(destinationPath);
108
+ if (srcParent === dstParent) {
109
+ if (srcName === dstName)
110
+ return;
111
+ await this.run(["filesystem", "rename", sourcePath, dstName]);
112
+ return;
113
+ }
114
+ await this.run(["filesystem", "move", sourcePath, dstParent]);
115
+ if (srcName !== dstName) {
116
+ const movedPath = posixPath.join(dstParent, srcName);
117
+ await this.run(["filesystem", "rename", movedPath, dstName]);
118
+ }
80
119
  }
120
+ // `filesystem delete` permanently deletes — but only items already inside
121
+ // /trash or /photos-trash (the CLI rejects live paths). No --confirm flag
122
+ // exists on the CLI side; our own confirmed-gate lives in the MCP layer.
81
123
  async delete(remotePath) {
82
- await this.run(["filesystem", "delete", "--confirm", remotePath]);
124
+ await this.run(["filesystem", "delete", remotePath]);
83
125
  }
84
126
  // Sharing
85
127
  async shareStatus(remotePath) {
@@ -111,33 +153,57 @@ export class DriveService {
111
153
  remotePath,
112
154
  ]);
113
155
  }
156
+ // `sharing revoke` doesn't exist — it's `sharing remove --email <email>`.
114
157
  async shareRevoke(remotePath, email) {
115
- await this.run(["sharing", "revoke", "--user", email, remotePath]);
158
+ await this.shareRemove(remotePath, [email], false);
159
+ }
160
+ // General form of remove: specific emails, or --everyone to strip all
161
+ // members and pending invitations (Proton and non-Proton) in one call.
162
+ async shareRemove(remotePath, emails, everyone) {
163
+ const args = ["sharing", "remove"];
164
+ for (const email of emails)
165
+ args.push("--email", email);
166
+ if (everyone)
167
+ args.push("--everyone");
168
+ args.push(remotePath);
169
+ await this.run(args);
170
+ }
171
+ async shareSetUrl(remotePath, role = "viewer", password, expiration) {
172
+ const args = ["sharing", "set-url", remotePath, "--role", role];
173
+ if (password)
174
+ args.push("--password", password);
175
+ if (expiration)
176
+ args.push("--expiration", expiration);
177
+ const result = await this.run(args);
178
+ return this.parsePublicLink(result);
179
+ }
180
+ async shareRemoveUrl(remotePath) {
181
+ await this.run(["sharing", "remove-url", remotePath]);
182
+ }
183
+ parsePublicLink(result) {
184
+ const r = (result ?? {});
185
+ const urlAccess = (r.urlAccess ?? {});
186
+ const url = r.url ?? urlAccess.url;
187
+ const role = r.role ?? urlAccess.role;
188
+ const expirationTime = r.expirationTime ?? urlAccess.expirationTime;
189
+ return {
190
+ url: typeof url === "string" ? url : undefined,
191
+ role: (["viewer", "editor", "admin"].includes(String(role)) ? role : undefined),
192
+ expirationTime: typeof expirationTime === "string" ? expirationTime : undefined,
193
+ };
116
194
  }
117
195
  // Trash
118
196
  async listTrash() {
119
- const result = await this.run(["trash", "list"]);
120
- if (result === null)
121
- return [];
122
- if (!Array.isArray(result))
123
- throw new DriveParseError(`Expected array from trash list, got: ${JSON.stringify(result).slice(0, 100)}`);
124
- return result.map((item) => ({
125
- name: String(item.name ?? ""),
126
- path: String(item.path ?? ""),
127
- type: item.type === "folder" ? "folder" : "file",
128
- size: typeof item.size === "number" ? item.size : undefined,
129
- modifiedAt: typeof item.modifiedAt === "string" ? item.modifiedAt : undefined,
130
- mimeType: typeof item.mimeType === "string" ? item.mimeType : undefined,
131
- }));
197
+ return this.list("/trash");
132
198
  }
133
199
  async trash(remotePath) {
134
- await this.run(["trash", remotePath]);
200
+ await this.run(["filesystem", "trash", remotePath]);
135
201
  }
136
202
  async restore(remotePath) {
137
- await this.run(["trash", "restore", remotePath]);
203
+ await this.run(["filesystem", "restore", remotePath]);
138
204
  }
139
205
  async emptyTrash() {
140
- await this.run(["trash", "empty", "--confirm"]);
206
+ await this.run(["filesystem", "empty-trash"]);
141
207
  }
142
208
  async copy(remoteSrc, remoteDst) {
143
209
  await this.run(["filesystem", "copy", remoteSrc, remoteDst]);
@@ -188,6 +254,14 @@ export class DriveService {
188
254
  async createAlbum(name) {
189
255
  await this.run(["album", "create", name]);
190
256
  }
257
+ async updateAlbum(albumPath, name, coverPhotoUid) {
258
+ const args = ["album", "update", albumPath];
259
+ if (name)
260
+ args.push("--name", name);
261
+ if (coverPhotoUid)
262
+ args.push("--cover-photo-uid", coverPhotoUid);
263
+ await this.run(args);
264
+ }
191
265
  async deleteAlbum(albumPath, force, save) {
192
266
  const args = ["album", "delete", albumPath];
193
267
  if (force)
@@ -212,4 +286,42 @@ export class DriveService {
212
286
  async removePhotoFromAlbum(albumPath, photoPath) {
213
287
  await this.run(["album", "remove-photo", albumPath, photoPath]);
214
288
  }
289
+ // Photos timeline / library-level transfers (distinct from album-scoped
290
+ // filesystem-style paths above — these hit the `photo` CLI group).
291
+ async photoTimeline(loadDetails) {
292
+ const args = ["photo", "timeline"];
293
+ if (loadDetails)
294
+ args.push("--load-details");
295
+ const result = await this.run(args);
296
+ if (result === null)
297
+ return [];
298
+ if (!Array.isArray(result))
299
+ throw new DriveParseError(`Expected array from photo timeline, got: ${JSON.stringify(result).slice(0, 100)}`);
300
+ return result.map((item) => ({
301
+ nodeUid: String(item.nodeUid ?? item.uid ?? ""),
302
+ }));
303
+ }
304
+ async photoDownload(remotePaths, localFolder, conflictStrategy = "skip") {
305
+ const result = await this.run([
306
+ "photo", "download", ...remotePaths, localFolder,
307
+ "--conflict-strategy", conflictStrategy,
308
+ ]);
309
+ return this.parseTransferSummary(result);
310
+ }
311
+ async photoUpload(localPaths, conflictStrategy = "skip") {
312
+ const result = await this.run([
313
+ "photo", "upload", ...localPaths,
314
+ "--conflict-strategy", conflictStrategy,
315
+ ]);
316
+ return this.parseTransferSummary(result);
317
+ }
318
+ parseTransferSummary(result) {
319
+ const r = (result ?? {});
320
+ return {
321
+ transferredItems: typeof r.transferredItems === "number" ? r.transferredItems : 0,
322
+ transferredBytes: typeof r.transferredBytes === "number" ? r.transferredBytes : 0,
323
+ skippedItems: typeof r.skippedItems === "number" ? r.skippedItems : 0,
324
+ failedItems: typeof r.failedItems === "number" ? r.failedItems : 0,
325
+ };
326
+ }
215
327
  }
@@ -7,13 +7,45 @@ const DEFAULT_TIMEOUT_MS = 60_000;
7
7
  // Upload and download transfer actual file bytes — use a much longer timeout.
8
8
  const TRANSFER_TIMEOUT_MS = 30 * 60_000; // 30 minutes
9
9
  const TRANSFER_COMMANDS = new Set(["upload", "download"]);
10
+ const TRANSFER_GROUPS = new Set(["filesystem", "photo"]);
10
11
  function timeoutFor(args) {
11
12
  // args[0] is the group (e.g. "filesystem"), args[1] is the subcommand
12
- if (args[0] === "filesystem" && args[1] && TRANSFER_COMMANDS.has(args[1])) {
13
+ if (args[0] && TRANSFER_GROUPS.has(args[0]) && args[1] && TRANSFER_COMMANDS.has(args[1])) {
13
14
  return TRANSFER_TIMEOUT_MS;
14
15
  }
15
16
  return DEFAULT_TIMEOUT_MS;
16
17
  }
18
+ function isAuthError(stderr) {
19
+ const lower = stderr.toLowerCase();
20
+ return (lower.includes("not authenticated") ||
21
+ lower.includes("not logged in") ||
22
+ lower.includes("you need to login first"));
23
+ }
24
+ // Normalizes a raw execFile failure into one of our typed errors. Shared by
25
+ // runDrive and runDriveRaw so both entry points fail the same way.
26
+ function normalizeExecError(err, args) {
27
+ if (err instanceof DriveCliError ||
28
+ err instanceof DriveNotAuthenticatedError ||
29
+ err instanceof DriveParseError) {
30
+ throw err;
31
+ }
32
+ const e = err;
33
+ if (e.code === "ENOENT") {
34
+ throw new DriveCliNotFoundError();
35
+ }
36
+ if (e.killed === true || e.code === "ETIMEDOUT") {
37
+ const secs = timeoutFor(args) / 1000;
38
+ throw new DriveCliError(`CLI process timed out after ${secs}s`, "");
39
+ }
40
+ if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
41
+ throw new DriveCliError("CLI output exceeded buffer limit (50 MB)", "");
42
+ }
43
+ const stderr = e.stderr ?? "";
44
+ if (isAuthError(stderr)) {
45
+ throw new DriveNotAuthenticatedError();
46
+ }
47
+ throw new DriveCliError(e.message ?? String(e), stderr);
48
+ }
17
49
  export async function runDrive(args) {
18
50
  try {
19
51
  const { stdout, stderr } = await execFileAsync(CLI_BINARY, [...args, "--json"], { timeout: timeoutFor(args), maxBuffer: 50 * 1024 * 1024, killSignal: "SIGKILL" });
@@ -21,11 +53,8 @@ export async function runDrive(args) {
21
53
  // Only check stderr for auth errors when stdout is empty. If the CLI wrote
22
54
  // valid JSON, we honour it even when warnings appear on stderr.
23
55
  if (!raw) {
24
- if (stderr && stderr.trim()) {
25
- const lower = stderr.toLowerCase();
26
- if (lower.includes("not authenticated") || lower.includes("not logged in")) {
27
- throw new DriveNotAuthenticatedError();
28
- }
56
+ if (stderr && stderr.trim() && isAuthError(stderr)) {
57
+ throw new DriveNotAuthenticatedError();
29
58
  }
30
59
  return null;
31
60
  }
@@ -41,28 +70,21 @@ export async function runDrive(args) {
41
70
  }
42
71
  }
43
72
  catch (err) {
44
- if (err instanceof DriveCliError ||
45
- err instanceof DriveNotAuthenticatedError ||
46
- err instanceof DriveParseError) {
47
- throw err;
48
- }
49
- const e = err;
50
- if (e.code === "ENOENT") {
51
- throw new DriveCliNotFoundError();
52
- }
53
- if (e.killed === true || e.code === "ETIMEDOUT") {
54
- const secs = timeoutFor(args) / 1000;
55
- throw new DriveCliError(`CLI process timed out after ${secs}s`, "");
56
- }
57
- if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
58
- throw new DriveCliError("CLI output exceeded buffer limit (50 MB)", "");
59
- }
60
- const stderr = e.stderr ?? "";
61
- const lower = stderr.toLowerCase();
62
- if (lower.includes("not authenticated") || lower.includes("not logged in")) {
73
+ normalizeExecError(err, args);
74
+ }
75
+ }
76
+ // Some CLI commands (namely `version`) ignore --json entirely and always
77
+ // print plain text. This runs without appending --json and returns raw stdout.
78
+ export async function runDriveRaw(args) {
79
+ try {
80
+ const { stdout, stderr } = await execFileAsync(CLI_BINARY, args, { timeout: timeoutFor(args), maxBuffer: 50 * 1024 * 1024, killSignal: "SIGKILL" });
81
+ if (!stdout.trim() && stderr && stderr.trim() && isAuthError(stderr)) {
63
82
  throw new DriveNotAuthenticatedError();
64
83
  }
65
- throw new DriveCliError(e.message ?? String(e), stderr);
84
+ return stdout;
85
+ }
86
+ catch (err) {
87
+ normalizeExecError(err, args);
66
88
  }
67
89
  }
68
90
  // Returns false only when the binary is not installed (ENOENT) or not executable (EACCES).
@@ -70,7 +92,7 @@ export async function runDrive(args) {
70
92
  // is present and real errors will surface on the first actual tool call.
71
93
  export async function checkCliAvailable() {
72
94
  try {
73
- await execFileAsync(CLI_BINARY, ["version", "--json"], { timeout: 5_000 });
95
+ await execFileAsync(CLI_BINARY, ["version"], { timeout: 5_000 });
74
96
  return { available: true };
75
97
  }
76
98
  catch (e) {
package/glama.json CHANGED
@@ -126,6 +126,34 @@
126
126
  {
127
127
  "name": "photos_remove_from_album",
128
128
  "description": "Remove a photo from a Proton Photos album."
129
+ },
130
+ {
131
+ "name": "drive_share_set_url",
132
+ "description": "Create or update a public share link for a Proton Drive file or folder."
133
+ },
134
+ {
135
+ "name": "drive_share_remove_url",
136
+ "description": "Remove the public share link from a Proton Drive file or folder."
137
+ },
138
+ {
139
+ "name": "drive_share_remove_all",
140
+ "description": "Remove all members and pending invitations from a shared Proton Drive path in one call."
141
+ },
142
+ {
143
+ "name": "photos_update_album",
144
+ "description": "Rename an album or change its cover photo in Proton Photos."
145
+ },
146
+ {
147
+ "name": "photos_list_timeline",
148
+ "description": "List photos in your Proton Photos timeline."
149
+ },
150
+ {
151
+ "name": "photos_download",
152
+ "description": "Download one or more photos from Proton Photos to a local folder."
153
+ },
154
+ {
155
+ "name": "photos_upload",
156
+ "description": "Upload local photos or videos directly into your Proton Photos library."
129
157
  }
130
158
  ]
131
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proton-drive-mcp",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "MCP server and CLI that gives Claude full access to Proton Drive — upload, download, share, and manage your end-to-end encrypted files without leaving the conversation.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/smithery.yaml CHANGED
@@ -41,6 +41,13 @@ tools:
41
41
  - photos_list_album_photos
42
42
  - photos_add_to_album
43
43
  - photos_remove_from_album
44
+ - drive_share_set_url
45
+ - drive_share_remove_url
46
+ - drive_share_remove_all
47
+ - photos_update_album
48
+ - photos_list_timeline
49
+ - photos_download
50
+ - photos_upload
44
51
  startCommand:
45
52
  type: stdio
46
53
  configSchema: