proteworks 0.1.4__tar.gz → 0.1.9__tar.gz

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.
Files changed (67) hide show
  1. {proteworks-0.1.4 → proteworks-0.1.9}/PKG-INFO +78 -21
  2. {proteworks-0.1.4 → proteworks-0.1.9}/README.md +76 -19
  3. {proteworks-0.1.4 → proteworks-0.1.9}/pyproject.toml +3 -2
  4. proteworks-0.1.9/src/proteworks/__init__.py +13 -0
  5. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/api.py +204 -10
  6. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/browse.py +5 -4
  7. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/cli.py +172 -7
  8. proteworks-0.1.9/src/proteworks/config.py +170 -0
  9. proteworks-0.1.9/src/proteworks/folders.py +117 -0
  10. proteworks-0.1.9/src/proteworks/mcp/errors.py +87 -0
  11. proteworks-0.1.9/src/proteworks/mcp/paths.py +128 -0
  12. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/mcp/profile.py +2 -2
  13. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/mcp/server.py +16 -5
  14. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/mcp/tools_files.py +86 -17
  15. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/mcp/tools_orgs.py +43 -24
  16. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/mcp/tools_workflows.py +151 -5
  17. proteworks-0.1.9/src/proteworks/mcp_host.py +969 -0
  18. proteworks-0.1.9/src/proteworks/push.py +890 -0
  19. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/results.py +23 -0
  20. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/session.py +7 -1
  21. proteworks-0.1.9/src/proteworks/studio/kinds/rfd4_proteina.py +88 -0
  22. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/registry.py +7 -0
  23. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/workflows.py +53 -0
  24. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks.egg-info/PKG-INFO +78 -21
  25. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks.egg-info/SOURCES.txt +10 -0
  26. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks.egg-info/requires.txt +1 -1
  27. {proteworks-0.1.4 → proteworks-0.1.9}/tests/test_cli.py +371 -28
  28. proteworks-0.1.9/tests/test_config_dir.py +34 -0
  29. proteworks-0.1.9/tests/test_folders.py +147 -0
  30. {proteworks-0.1.4 → proteworks-0.1.9}/tests/test_mcp_adversarial.py +1 -1
  31. proteworks-0.1.9/tests/test_mcp_errors.py +31 -0
  32. {proteworks-0.1.4 → proteworks-0.1.9}/tests/test_mcp_files.py +22 -0
  33. proteworks-0.1.9/tests/test_mcp_host.py +315 -0
  34. proteworks-0.1.9/tests/test_mcp_paths.py +45 -0
  35. {proteworks-0.1.4 → proteworks-0.1.9}/tests/test_mcp_workflows.py +25 -0
  36. proteworks-0.1.9/tests/test_rfd4_proteina_workflow.py +57 -0
  37. proteworks-0.1.9/tests/test_token_store.py +70 -0
  38. {proteworks-0.1.4 → proteworks-0.1.9}/tests/test_transfer_result.py +50 -4
  39. proteworks-0.1.4/src/proteworks/__init__.py +0 -3
  40. proteworks-0.1.4/src/proteworks/config.py +0 -88
  41. proteworks-0.1.4/src/proteworks/mcp/errors.py +0 -32
  42. proteworks-0.1.4/src/proteworks/mcp/paths.py +0 -47
  43. proteworks-0.1.4/src/proteworks/push.py +0 -147
  44. {proteworks-0.1.4 → proteworks-0.1.9}/setup.cfg +0 -0
  45. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/auth.py +0 -0
  46. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/index.py +0 -0
  47. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/mcp/__init__.py +0 -0
  48. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/mcp/runtime.py +0 -0
  49. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/mcp/tools_studio.py +0 -0
  50. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/orgs.py +0 -0
  51. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/pull.py +0 -0
  52. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/redact.py +0 -0
  53. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/__init__.py +0 -0
  54. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/common.py +0 -0
  55. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/kinds/__init__.py +0 -0
  56. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/kinds/base.py +0 -0
  57. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/kinds/proteinmpnn.py +0 -0
  58. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/kinds/refolding.py +0 -0
  59. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/kinds/rfd3.py +0 -0
  60. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio/kinds/structure_prediction.py +0 -0
  61. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks/studio_proteinmpnn.py +0 -0
  62. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks.egg-info/dependency_links.txt +0 -0
  63. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks.egg-info/entry_points.txt +0 -0
  64. {proteworks-0.1.4 → proteworks-0.1.9}/src/proteworks.egg-info/top_level.txt +0 -0
  65. {proteworks-0.1.4 → proteworks-0.1.9}/tests/test_browse.py +0 -0
  66. {proteworks-0.1.4 → proteworks-0.1.9}/tests/test_mcp_studio.py +0 -0
  67. {proteworks-0.1.4 → proteworks-0.1.9}/tests/test_session.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proteworks
3
- Version: 0.1.4
3
+ Version: 0.1.9
4
4
  Summary: Sync files with Proteworks using file-browser paths.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: httpx>=0.27
7
+ Requires-Dist: httpx[socks]>=0.27
8
8
  Requires-Dist: keyring>=25
9
9
  Provides-Extra: dev
10
10
  Requires-Dist: pytest>=8; extra == "dev"
@@ -16,37 +16,94 @@ Requires-Dist: pyarrow>=14; extra == "mcp"
16
16
 
17
17
  Command line client and local MCP server for syncing Proteworks files and submitting allowlisted workflows. The default API is production. Set `PROTEWORKS_API_URL` only when testing against staging or a local stack.
18
18
 
19
+ ## One install (CLI + Claude Science)
20
+
21
+ There is a **single supported install**:
22
+
23
+ | Path | Role |
24
+ | --- | --- |
25
+ | `/opt/proteworks` | Package + `proteworks-mcp` |
26
+ | `/usr/local/bin/proteworks` | CLI symlink |
27
+ | `/usr/local/bin/proteworks-mcp` | MCP symlink (Science **Command**) |
28
+ | `~/.config/proteworks/` | Login token file + org config |
29
+
30
+ Do **not** keep a second copy from `uv tool` / pipx / a repo `.venv` on your PATH for daily use. Those diverge and break Claude Science.
31
+
32
+ ### Bootstrap once, then use only `/usr/local/bin`
33
+
19
34
  ```bash
20
- uv tool install proteworks
35
+ # 1) Temporary bootstrap (any machine with uv)
36
+ uv tool install 'proteworks[mcp]'
37
+
38
+ # 2) Canonical install (needs sudo). Uses distro python3.12+ under /usr (not uv under $HOME).
39
+ sudo "$(which proteworks)" mcp install-system
40
+ # Developers working from a git checkout before publishing:
41
+ # sudo "$(which proteworks)" mcp install-system --package-spec "$PWD[mcp]"
42
+
43
+ # 3) Remove the bootstrap copy so PATH has one binary
44
+ uv tool uninstall proteworks
45
+ hash -r
46
+ which -a proteworks # expect only /usr/local/bin/proteworks
47
+
48
+ # 4) Login + Science grants
21
49
  proteworks login
22
- proteworks organization list
23
50
  proteworks organization set "My Org"
24
- proteworks pull '~/My Files/campaigns/run/' ./run
25
- proteworks push ./1UBQ.pdb '~/My Files/test/'
26
- proteworks push ./run '~/My Files/campaigns/run/'
51
+ proteworks mcp setup-claude-science
52
+ proteworks mcp doctor
27
53
  ```
28
54
 
29
- Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
55
+ After any upgrade: run step 2 again (same `install-system` command), then `doctor`. Do not `uv tool install` again unless `/usr/local/bin/proteworks` is missing.
30
56
 
31
- Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs. The same URL is used in development and production.
57
+ ### Claude Science connector
32
58
 
33
- ## MCP (Claude Science and other clients)
59
+ `setup-claude-science` prints the fields. Always:
34
60
 
35
- The same login and organization selection power a local MCP server. Install the MCP extra, sign in once with the CLI, then point your client at `proteworks-mcp`.
61
+ | Field | Value |
62
+ | --- | --- |
63
+ | **Name** | `proteworks` |
64
+ | **Command** | `/opt/proteworks/bin/proteworks-mcp` |
65
+ | **Arguments** | leave empty |
66
+ | **Env** (if shown) | `PROTEWORKS_CONFIG_DIR=$HOME/.config/proteworks` |
67
+
68
+ Paste **only** that Command path. Do not use `python -m …`, `uvx`, or a path under `~/.local`. Science’s one-field UI can drop `-m` and hang for 180s; sandboxed `HOME` also cannot see a second user-level install’s credentials unless you use the system layout above.
69
+
70
+ Fully quit and relaunch Claude Science after setup so it reloads `config.toml`.
71
+ Also add each host **by name** under Science **Settings → Domain Allowlist**
72
+ (Science rejects wildcards). `setup-claude-science` prints the list — typically
73
+ `api.studio.proteworks.com` plus virtual-hosted GCS hosts such as
74
+ `proteworks-production.storage.googleapis.com`. The connector’s `config.toml`
75
+ grants are necessary but Science’s UI allowlist often gates egress separately.
76
+ Bare `storage.googleapis.com` is hard-denylisted (path-style GCS). If pull still
77
+ fails for a custom org bucket, add `<bucket>.storage.googleapis.com`. Science may
78
+ set `ALL_PROXY` (SOCKS); `proteworks` depends on `httpx[socks]` so that works out
79
+ of the box. If a direct GCS PUT is still blocked, push falls back to uploading
80
+ through the Proteworks API host.
81
+
82
+ Allow Claude to push/pull from a real project (like the CLI — no staging copy):
36
83
 
37
84
  ```bash
38
- uv tool install 'proteworks[mcp]'
39
- # or: pip install 'proteworks[mcp]'
40
- proteworks login
41
- proteworks organization set "My Org"
85
+ proteworks mcp setup-claude-science --mcp-root ~/projects/my-campaign
86
+ ```
87
+
88
+ That grants the Science sandbox **and** writes `~/.config/proteworks/mcp_roots`.
89
+ Restart the connector afterward. Absolute paths under that project then work with
90
+ MCP `push` / `pull`.
91
+
92
+ `proteworks --version` / `proteworks-mcp --version` print the installed version.
93
+
94
+ Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
95
+
96
+ ```bash
97
+ proteworks pull '~/My Files/campaigns/run/' ./run
98
+ proteworks push ./1UBQ.pdb '~/My Files/test/'
99
+ proteworks push '*.pdb' '~/My Files/pdbs/'
100
+ proteworks push designs.zip '~/My Files/dest/' # auto-extracts
101
+ proteworks mkdir '~/My Files/campaigns/round6/'
42
102
  ```
43
103
 
44
- ### Claude Science
104
+ MCP also exposes `create_folder` for the same empty-directory create (returns `folder_id` for `output_folder_id` / push).
45
105
 
46
- 1. Settings → Connectors → Add connector → **Local command**
47
- 2. Command: `proteworks-mcp`
48
- (or `uvx --from 'proteworks[mcp]' proteworks-mcp`)
49
- 3. Approve tools as needed
106
+ Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs.
50
107
 
51
108
  Example prompts: list `~/My Files/`, dry-run pull a folder, pull it, push results back.
52
109
 
@@ -76,7 +133,7 @@ Import via Settings → Skills → Import from GitHub (or upload the folder). Ho
76
133
  | `get_execution` / `list_execution_results` / `cancel_execution` | Status / results / cancel (`cancel_execution` needs `confirm=true`) |
77
134
  | `estimate_workflow_cost` | Cost estimate from merged params (same validation as prepare) |
78
135
 
79
- Auth is shared with the CLI (OS keychain + `~/.config/proteworks/`). There is no separate MCP login.
136
+ Auth is shared with the CLI. The access token is stored in the OS keychain and mirrored to `~/.config/proteworks/credentials` (mode `0600`) so Claude Science’s sandbox can read it. There is no separate MCP login: run `proteworks login` in a terminal, then restart the connector.
80
137
 
81
138
  ### MCP safety env
82
139
 
@@ -2,37 +2,94 @@
2
2
 
3
3
  Command line client and local MCP server for syncing Proteworks files and submitting allowlisted workflows. The default API is production. Set `PROTEWORKS_API_URL` only when testing against staging or a local stack.
4
4
 
5
+ ## One install (CLI + Claude Science)
6
+
7
+ There is a **single supported install**:
8
+
9
+ | Path | Role |
10
+ | --- | --- |
11
+ | `/opt/proteworks` | Package + `proteworks-mcp` |
12
+ | `/usr/local/bin/proteworks` | CLI symlink |
13
+ | `/usr/local/bin/proteworks-mcp` | MCP symlink (Science **Command**) |
14
+ | `~/.config/proteworks/` | Login token file + org config |
15
+
16
+ Do **not** keep a second copy from `uv tool` / pipx / a repo `.venv` on your PATH for daily use. Those diverge and break Claude Science.
17
+
18
+ ### Bootstrap once, then use only `/usr/local/bin`
19
+
5
20
  ```bash
6
- uv tool install proteworks
21
+ # 1) Temporary bootstrap (any machine with uv)
22
+ uv tool install 'proteworks[mcp]'
23
+
24
+ # 2) Canonical install (needs sudo). Uses distro python3.12+ under /usr (not uv under $HOME).
25
+ sudo "$(which proteworks)" mcp install-system
26
+ # Developers working from a git checkout before publishing:
27
+ # sudo "$(which proteworks)" mcp install-system --package-spec "$PWD[mcp]"
28
+
29
+ # 3) Remove the bootstrap copy so PATH has one binary
30
+ uv tool uninstall proteworks
31
+ hash -r
32
+ which -a proteworks # expect only /usr/local/bin/proteworks
33
+
34
+ # 4) Login + Science grants
7
35
  proteworks login
8
- proteworks organization list
9
36
  proteworks organization set "My Org"
10
- proteworks pull '~/My Files/campaigns/run/' ./run
11
- proteworks push ./1UBQ.pdb '~/My Files/test/'
12
- proteworks push ./run '~/My Files/campaigns/run/'
37
+ proteworks mcp setup-claude-science
38
+ proteworks mcp doctor
13
39
  ```
14
40
 
15
- Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
41
+ After any upgrade: run step 2 again (same `install-system` command), then `doctor`. Do not `uv tool install` again unless `/usr/local/bin/proteworks` is missing.
16
42
 
17
- Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs. The same URL is used in development and production.
43
+ ### Claude Science connector
18
44
 
19
- ## MCP (Claude Science and other clients)
45
+ `setup-claude-science` prints the fields. Always:
20
46
 
21
- The same login and organization selection power a local MCP server. Install the MCP extra, sign in once with the CLI, then point your client at `proteworks-mcp`.
47
+ | Field | Value |
48
+ | --- | --- |
49
+ | **Name** | `proteworks` |
50
+ | **Command** | `/opt/proteworks/bin/proteworks-mcp` |
51
+ | **Arguments** | leave empty |
52
+ | **Env** (if shown) | `PROTEWORKS_CONFIG_DIR=$HOME/.config/proteworks` |
53
+
54
+ Paste **only** that Command path. Do not use `python -m …`, `uvx`, or a path under `~/.local`. Science’s one-field UI can drop `-m` and hang for 180s; sandboxed `HOME` also cannot see a second user-level install’s credentials unless you use the system layout above.
55
+
56
+ Fully quit and relaunch Claude Science after setup so it reloads `config.toml`.
57
+ Also add each host **by name** under Science **Settings → Domain Allowlist**
58
+ (Science rejects wildcards). `setup-claude-science` prints the list — typically
59
+ `api.studio.proteworks.com` plus virtual-hosted GCS hosts such as
60
+ `proteworks-production.storage.googleapis.com`. The connector’s `config.toml`
61
+ grants are necessary but Science’s UI allowlist often gates egress separately.
62
+ Bare `storage.googleapis.com` is hard-denylisted (path-style GCS). If pull still
63
+ fails for a custom org bucket, add `<bucket>.storage.googleapis.com`. Science may
64
+ set `ALL_PROXY` (SOCKS); `proteworks` depends on `httpx[socks]` so that works out
65
+ of the box. If a direct GCS PUT is still blocked, push falls back to uploading
66
+ through the Proteworks API host.
67
+
68
+ Allow Claude to push/pull from a real project (like the CLI — no staging copy):
22
69
 
23
70
  ```bash
24
- uv tool install 'proteworks[mcp]'
25
- # or: pip install 'proteworks[mcp]'
26
- proteworks login
27
- proteworks organization set "My Org"
71
+ proteworks mcp setup-claude-science --mcp-root ~/projects/my-campaign
72
+ ```
73
+
74
+ That grants the Science sandbox **and** writes `~/.config/proteworks/mcp_roots`.
75
+ Restart the connector afterward. Absolute paths under that project then work with
76
+ MCP `push` / `pull`.
77
+
78
+ `proteworks --version` / `proteworks-mcp --version` print the installed version.
79
+
80
+ Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
81
+
82
+ ```bash
83
+ proteworks pull '~/My Files/campaigns/run/' ./run
84
+ proteworks push ./1UBQ.pdb '~/My Files/test/'
85
+ proteworks push '*.pdb' '~/My Files/pdbs/'
86
+ proteworks push designs.zip '~/My Files/dest/' # auto-extracts
87
+ proteworks mkdir '~/My Files/campaigns/round6/'
28
88
  ```
29
89
 
30
- ### Claude Science
90
+ MCP also exposes `create_folder` for the same empty-directory create (returns `folder_id` for `output_folder_id` / push).
31
91
 
32
- 1. Settings → Connectors → Add connector → **Local command**
33
- 2. Command: `proteworks-mcp`
34
- (or `uvx --from 'proteworks[mcp]' proteworks-mcp`)
35
- 3. Approve tools as needed
92
+ Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs.
36
93
 
37
94
  Example prompts: list `~/My Files/`, dry-run pull a folder, pull it, push results back.
38
95
 
@@ -62,7 +119,7 @@ Import via Settings → Skills → Import from GitHub (or upload the folder). Ho
62
119
  | `get_execution` / `list_execution_results` / `cancel_execution` | Status / results / cancel (`cancel_execution` needs `confirm=true`) |
63
120
  | `estimate_workflow_cost` | Cost estimate from merged params (same validation as prepare) |
64
121
 
65
- Auth is shared with the CLI (OS keychain + `~/.config/proteworks/`). There is no separate MCP login.
122
+ Auth is shared with the CLI. The access token is stored in the OS keychain and mirrored to `~/.config/proteworks/credentials` (mode `0600`) so Claude Science’s sandbox can read it. There is no separate MCP login: run `proteworks login` in a terminal, then restart the connector.
66
123
 
67
124
  ### MCP safety env
68
125
 
@@ -1,11 +1,12 @@
1
1
  [project]
2
2
  name = "proteworks"
3
- version = "0.1.4"
3
+ version = "0.1.9"
4
4
  description = "Sync files with Proteworks using file-browser paths."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  dependencies = [
8
- "httpx>=0.27",
8
+ # socks: Claude Science sets ALL_PROXY; httpx needs socksio for that backend.
9
+ "httpx[socks]>=0.27",
9
10
  "keyring>=25",
10
11
  ]
11
12
 
@@ -0,0 +1,13 @@
1
+ """Proteworks sync client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ __version__ = version("proteworks")
10
+ except PackageNotFoundError:
11
+ __version__ = "0.1.9"
12
+ except Exception: # pragma: no cover
13
+ __version__ = "0.1.9"
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import os
6
7
  import time
8
+ from pathlib import Path
7
9
  from typing import Any, Optional
8
10
  from urllib.parse import urlencode, urlparse
9
11
 
@@ -199,26 +201,162 @@ class ApiClient:
199
201
  data={"name": name, "parent_id": parent_id},
200
202
  )
201
203
 
202
- def upload_init(self, filename: str, folder_id: str, size_bytes: int, content_type: str) -> dict:
203
- return self.request(
204
+ def upload_init(
205
+ self,
206
+ filename: str,
207
+ folder_id: str,
208
+ size_bytes: int,
209
+ content_type: str,
210
+ *,
211
+ upload_purpose: str = "default",
212
+ extraction_job_id: Optional[str] = None,
213
+ skip_existing: bool = False,
214
+ ) -> dict:
215
+ """Initialize a direct upload. Retries on HTTP 429 (shared upload rate limit)."""
216
+ body: dict[str, Any] = {
217
+ "filename": filename,
218
+ "folder_id": folder_id,
219
+ "content_type": content_type,
220
+ "size_bytes": size_bytes,
221
+ "upload_purpose": upload_purpose,
222
+ "skip_existing": skip_existing,
223
+ }
224
+ if extraction_job_id:
225
+ body["extraction_job_id"] = extraction_job_id
226
+ return self._request_with_backoff(
204
227
  "POST",
205
228
  "/api/v1/files/upload-init",
206
- json_body={
207
- "filename": filename,
208
- "folder_id": folder_id,
209
- "content_type": content_type,
210
- "size_bytes": size_bytes,
211
- "skip_existing": False,
212
- },
229
+ json_body=body,
213
230
  )
214
231
 
215
232
  def upload_complete(self, session_id: str, size_bytes: int) -> dict:
216
- return self.request(
233
+ """Finalize a direct upload. Retries on HTTP 429."""
234
+ return self._request_with_backoff(
217
235
  "POST",
218
236
  "/api/v1/files/upload-complete",
219
237
  json_body={"session_id": session_id, "size_bytes": size_bytes},
220
238
  )
221
239
 
240
+ def upload_batch(
241
+ self,
242
+ files: list[tuple[str, Path, str]],
243
+ folder_ids: list[str],
244
+ *,
245
+ skip_existing: bool = False,
246
+ ) -> dict:
247
+ """POST /files/upload-batch (multipart). Each file is (filename, path, content_type)."""
248
+ if len(files) != len(folder_ids):
249
+ raise ApiError("folder_ids length must match files length", 400)
250
+ if len(files) > 1000:
251
+ raise ApiError("Maximum 1000 files per upload-batch request", 400)
252
+ query: dict[str, str] = {}
253
+ if self.organization_id:
254
+ query["organization_id"] = self.organization_id
255
+ url = self.base_url + "/api/v1/files/upload-batch"
256
+ if query:
257
+ url = url + "?" + urlencode(query)
258
+ data = {"folder_ids": json.dumps(list(folder_ids))}
259
+ if skip_existing:
260
+ data["skip_existing"] = "true"
261
+ delay_seconds = 1.0
262
+ last_error: Optional[ApiError] = None
263
+ for attempt in range(6):
264
+ handles = []
265
+ try:
266
+ multipart = []
267
+ for filename, path, content_type in files:
268
+ handle = Path(path).open("rb")
269
+ handles.append(handle)
270
+ multipart.append(
271
+ (
272
+ "files",
273
+ (
274
+ filename,
275
+ handle,
276
+ content_type or "application/octet-stream",
277
+ ),
278
+ )
279
+ )
280
+ response = self._client.post(
281
+ url,
282
+ headers=self._headers(),
283
+ data=data,
284
+ files=multipart,
285
+ timeout=600.0,
286
+ )
287
+ if response.status_code == 429 and attempt < 5:
288
+ last_error = ApiError(public_detail(response), 429)
289
+ time.sleep(delay_seconds)
290
+ delay_seconds = min(delay_seconds * 2, 30.0)
291
+ continue
292
+ if response.status_code >= 400:
293
+ raise ApiError(public_detail(response), response.status_code)
294
+ if not response.content:
295
+ return {"successful": [], "failed": [], "skipped": []}
296
+ return response.json()
297
+ except ApiError as exc:
298
+ if exc.status_code != 429 or attempt == 5:
299
+ raise
300
+ last_error = exc
301
+ time.sleep(delay_seconds)
302
+ delay_seconds = min(delay_seconds * 2, 30.0)
303
+ finally:
304
+ for handle in handles:
305
+ handle.close()
306
+ raise last_error or ApiError("upload-batch failed", 500)
307
+
308
+ def exists_batch(self, items: list[dict[str, str]]) -> dict:
309
+ """POST /files/exists-batch. Each item is {folder_id, name}. Chunks to 2000."""
310
+ merged: list[dict] = []
311
+ chunk_size = 2000
312
+ for start in range(0, len(items), chunk_size):
313
+ chunk = items[start : start + chunk_size]
314
+ payload = self._request_with_backoff(
315
+ "POST",
316
+ "/api/v1/files/exists-batch",
317
+ json_body={"items": chunk},
318
+ )
319
+ merged.extend(list((payload or {}).get("existing") or []))
320
+ return {"existing": merged, "existing_count": len(merged), "requested_count": len(items)}
321
+
322
+ def create_archive_extraction_job(
323
+ self,
324
+ zip_filename: str,
325
+ zip_size: int,
326
+ extract_to_folder_id: str,
327
+ ) -> dict:
328
+ return self._request_with_backoff(
329
+ "POST",
330
+ "/api/v1/files/archive-extraction-jobs",
331
+ json_body={
332
+ "zip_filename": zip_filename,
333
+ "zip_size": zip_size,
334
+ "extract_to_folder_id": extract_to_folder_id,
335
+ },
336
+ )
337
+
338
+ def start_archive_extraction(
339
+ self,
340
+ file_id: str,
341
+ extract_to_folder_id: str,
342
+ *,
343
+ job_id: Optional[str] = None,
344
+ ) -> dict:
345
+ body: dict[str, Any] = {
346
+ "file_id": file_id,
347
+ "extract_to_folder_id": extract_to_folder_id,
348
+ }
349
+ if job_id:
350
+ body["job_id"] = job_id
351
+ return self._request_with_backoff(
352
+ "POST",
353
+ "/api/v1/files/archive-extraction",
354
+ json_body=body,
355
+ )
356
+
357
+ def get_zip_extraction_job(self, job_id: str) -> dict:
358
+ return self.request("GET", f"/api/v1/files/zip-extraction-jobs/{job_id}")
359
+
222
360
  def download_to(self, url: str, path) -> None:
223
361
  """Stream bytes to a file. The URL is not logged."""
224
362
  assert_allowed_storage_url(url)
@@ -236,6 +374,32 @@ class ApiClient:
236
374
  if response.status_code >= 400:
237
375
  raise ApiError(f"Upload to storage failed ({response.status_code})", response.status_code)
238
376
 
377
+ def upload_multipart(self, filename: str, folder_id: str, path, content_type: str) -> dict:
378
+ """Upload via POST /api/v1/files/upload (API proxy; avoids direct GCS egress).
379
+
380
+ Used when the sandbox blocks path-style ``storage.googleapis.com`` (Claude Science
381
+ denylist) so bytes can still reach Studio through the allowlisted API host.
382
+ """
383
+ query: dict[str, str] = {}
384
+ if self.organization_id:
385
+ query["organization_id"] = self.organization_id
386
+ url = self.base_url + "/api/v1/files/upload"
387
+ if query:
388
+ url = url + "?" + urlencode(query)
389
+ headers = self._headers()
390
+ with open(path, "rb") as handle:
391
+ response = self._client.post(
392
+ url,
393
+ headers=headers,
394
+ data={"folder_id": folder_id},
395
+ files={"file": (filename, handle, content_type or "application/octet-stream")},
396
+ )
397
+ if response.status_code >= 400:
398
+ raise ApiError(public_detail(response), response.status_code)
399
+ if not response.content:
400
+ return {}
401
+ return response.json()
402
+
239
403
  def list_agent_schemas(self) -> dict:
240
404
  return self.request("GET", "/api/v1/workflows/agent-schemas")
241
405
 
@@ -248,6 +412,36 @@ class ApiClient:
248
412
  def get_execution(self, execution_id: str) -> dict:
249
413
  return self.request("GET", f"/api/v1/workflows/executions/{execution_id}")
250
414
 
415
+ def get_execution_detailed_status(
416
+ self,
417
+ execution_id: str,
418
+ *,
419
+ steps_limit: Optional[int] = None,
420
+ ) -> dict:
421
+ """GET detailed Pod-step status (Argo) for an execution."""
422
+ params: dict[str, Any] = {}
423
+ if steps_limit is not None:
424
+ params["steps_limit"] = int(steps_limit)
425
+ return self.request(
426
+ "GET",
427
+ f"/api/v1/workflows/executions/{execution_id}/status/detailed",
428
+ params=params or None,
429
+ )
430
+
431
+ def get_execution_step_logs(
432
+ self,
433
+ execution_id: str,
434
+ step_name: str,
435
+ *,
436
+ tail_lines: int = 500,
437
+ ) -> dict:
438
+ """GET logs for one workflow step (Pod node)."""
439
+ return self.request(
440
+ "GET",
441
+ f"/api/v1/workflows/executions/{execution_id}/steps/{step_name}/logs",
442
+ params={"tail_lines": int(tail_lines)},
443
+ )
444
+
251
445
  def list_execution_results(self, execution_id: str) -> list:
252
446
  payload = self.request("GET", f"/api/v1/workflows/executions/{execution_id}/results")
253
447
  return list(payload or [])
@@ -12,12 +12,13 @@ def list_remote(api: ApiClient, path: str, *, limit: int = 200) -> dict[str, Any
12
12
  List files under a file-browser path.
13
13
 
14
14
  Returns agent-safe fields only (includes file_id for future cloud submit).
15
- Caps the item list at ``limit`` and sets truncated=true when more remain.
15
+ Fetches a single manifest page of ``limit`` items (does not walk the whole
16
+ tree). Sets truncated=true when the API reports more remain.
16
17
  """
17
18
  if limit < 1:
18
19
  raise ValueError("limit must be at least 1")
19
20
  try:
20
- page = api.manifest_all(path)
21
+ page = api.manifest(path, 0, limit=limit)
21
22
  except ApiError as exc:
22
23
  return {
23
24
  "ok": False,
@@ -29,8 +30,8 @@ def list_remote(api: ApiClient, path: str, *, limit: int = 200) -> dict[str, Any
29
30
  }
30
31
  raw_items = list(page.get("items") or [])
31
32
  total = int(page.get("total") or len(raw_items))
32
- truncated = len(raw_items) > limit
33
- items = [_normalize_item(item) for item in raw_items[:limit]]
33
+ truncated = total > len(raw_items)
34
+ items = [_normalize_item(item) for item in raw_items]
34
35
  return {
35
36
  "ok": True,
36
37
  "items": items,