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 +26 -0
- package/README.md +48 -21
- package/dist/cli.js +128 -31
- package/dist/index.js +264 -15
- package/dist/services/drive.js +152 -40
- package/dist/utils/subprocess.js +49 -27
- package/glama.json +28 -0
- package/package.json +1 -1
- package/smithery.yaml +7 -0
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
|
|
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 #
|
|
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
|
|
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
|
|
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
|
|
189
|
-
proton-drive-cli trash list
|
|
190
|
-
proton-drive-cli restore /my-files/old-draft.pdf
|
|
191
|
-
proton-drive-cli trash empty
|
|
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
|
|
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
|
|
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/
|
|
250
|
-
| `drive_download` | Download to local path | `remotePath`, `localPath` |
|
|
251
|
-
| `drive_move` | Move or rename | `sourcePath`, `destinationPath` |
|
|
252
|
-
| `
|
|
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`
|
|
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
|
|
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
|
-
*
|
|
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/
|
|
45
|
-
download <remote> <local>
|
|
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>
|
|
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
|
|
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
|
|
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", "
|
|
164
|
-
console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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'
|
|
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", "
|
|
116
|
+
enum: ["skip", "replace", "keep-both", "merge"],
|
|
117
117
|
description: "'skip' leaves existing remote files unchanged (default). " +
|
|
118
|
-
"'
|
|
119
|
-
"'
|
|
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
|
-
"
|
|
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
|
|
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
|
|
197
|
-
"
|
|
198
|
-
"
|
|
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", "
|
|
664
|
-
return fail(`conflictStrategy must be skip,
|
|
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
|
-
|
|
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)
|
package/dist/services/drive.js
CHANGED
|
@@ -1,32 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
return { authenticated:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
29
|
-
sdk:
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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",
|
|
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.
|
|
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
|
-
|
|
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(["
|
|
203
|
+
await this.run(["filesystem", "restore", remotePath]);
|
|
138
204
|
}
|
|
139
205
|
async emptyTrash() {
|
|
140
|
-
await this.run(["
|
|
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
|
}
|
package/dist/utils/subprocess.js
CHANGED
|
@@ -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]
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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"
|
|
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.
|
|
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:
|