japanfold 1.0.0__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.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: japanfold
3
+ Version: 1.0.0
4
+ Summary: Command-line client for JapanFold: protein structure prediction, binder design and embeddings over an HTTP API.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://japanfold.aiand.com
7
+ Project-URL: Documentation, https://japanfold.aiand.com/docs
8
+ Keywords: protein,structure-prediction,boltz,esmfold,binder-design,bioinformatics
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # japanfold
13
+
14
+ Command-line client for [JapanFold](https://japanfold.aiand.com), a hosted API for protein
15
+ structure prediction, binder design and protein embeddings. No GPU and no model weights on your
16
+ side; the models run on JapanFold's servers in Japan.
17
+
18
+ ```bash
19
+ pip install japanfold
20
+ export JAPANFOLD_API_KEY=jf_live_... # from https://japanfold.aiand.com/account/token
21
+ japanfold fold --sequence MKTAYIAKQRQISFVKSHFSRQ --wait --out ./out
22
+ ```
23
+
24
+ It has no dependencies beyond the Python standard library (3.9+).
25
+
26
+ ## Key and endpoint
27
+
28
+ Every call needs a key. Sign in at [japanfold.aiand.com/account/token](https://japanfold.aiand.com/account/token)
29
+ to get one; a new account comes with $100 of credit. The CLI reads it from `--api-key`, then
30
+ `$JAPANFOLD_API_KEY`, then `~/.config/japanfold/config.json` (written by `japanfold auth login`).
31
+
32
+ The API lives at `https://api.japanfold.aiand.com`. Set `JAPANFOLD_BASE_URL` or `--base-url` to
33
+ point somewhere else.
34
+
35
+ ## Commands
36
+
37
+ ```bash
38
+ japanfold models # every model and design protocol
39
+ japanfold fold --sequence MKTAYIAK... --model boltz2 --wait --out ./out
40
+ japanfold design spec.yaml --model boltzgen --protocol nanobody-anything --wait --out ./out
41
+ japanfold design --model rfd3 --protocol rfd3-binder --structure target.pdb \
42
+ --contig 'A1-150,60-80' --wait --out ./out
43
+ japanfold design --model pxdesign --protocol pxdesign-binder --structure target.pdb \
44
+ --chains A --binder-length 80 --hotspots A64,A65,A66 --wait --out ./out
45
+ japanfold embed --sequence MKTAYIAK... --model esmc-600m --wait --out ./out
46
+ japanfold wait JOB_ID --out ./out # wait for a job and download it
47
+ japanfold credits # balance left on the key
48
+ japanfold jobs list
49
+ ```
50
+
51
+ `--wait` submits, waits for the job and downloads the results in one foreground command.
52
+ Without it a command prints the job id and returns; `japanfold wait JOB_ID` picks it up later
53
+ and is safe to re-run. `fold` is the same command as `predict`, and `wait` the same as `download`.
54
+
55
+ ## For agents
56
+
57
+ `--json` on any command prints machine-readable JSON on stdout and keeps human messages on
58
+ stderr. `japanfold schema` prints the OpenAPI 3.1 contract. Exit codes say what went wrong
59
+ without parsing text:
60
+
61
+ | Code | Meaning |
62
+ |---|---|
63
+ | 0 | success |
64
+ | 1 | job failed or generic error |
65
+ | 2 | missing or invalid key |
66
+ | 3 | invalid input |
67
+ | 4 | not found |
68
+ | 5 | rate-limited or at capacity |
69
+ | 6 | server unreachable |
70
+
71
+ Agents that prefer plain HTTP can skip the CLI: the [docs](https://japanfold.aiand.com/docs/)
72
+ cover the REST API, and the agent skill lives at
73
+ [github.com/japanfold/japanfold](https://github.com/japanfold/japanfold).
@@ -0,0 +1,62 @@
1
+ # japanfold
2
+
3
+ Command-line client for [JapanFold](https://japanfold.aiand.com), a hosted API for protein
4
+ structure prediction, binder design and protein embeddings. No GPU and no model weights on your
5
+ side; the models run on JapanFold's servers in Japan.
6
+
7
+ ```bash
8
+ pip install japanfold
9
+ export JAPANFOLD_API_KEY=jf_live_... # from https://japanfold.aiand.com/account/token
10
+ japanfold fold --sequence MKTAYIAKQRQISFVKSHFSRQ --wait --out ./out
11
+ ```
12
+
13
+ It has no dependencies beyond the Python standard library (3.9+).
14
+
15
+ ## Key and endpoint
16
+
17
+ Every call needs a key. Sign in at [japanfold.aiand.com/account/token](https://japanfold.aiand.com/account/token)
18
+ to get one; a new account comes with $100 of credit. The CLI reads it from `--api-key`, then
19
+ `$JAPANFOLD_API_KEY`, then `~/.config/japanfold/config.json` (written by `japanfold auth login`).
20
+
21
+ The API lives at `https://api.japanfold.aiand.com`. Set `JAPANFOLD_BASE_URL` or `--base-url` to
22
+ point somewhere else.
23
+
24
+ ## Commands
25
+
26
+ ```bash
27
+ japanfold models # every model and design protocol
28
+ japanfold fold --sequence MKTAYIAK... --model boltz2 --wait --out ./out
29
+ japanfold design spec.yaml --model boltzgen --protocol nanobody-anything --wait --out ./out
30
+ japanfold design --model rfd3 --protocol rfd3-binder --structure target.pdb \
31
+ --contig 'A1-150,60-80' --wait --out ./out
32
+ japanfold design --model pxdesign --protocol pxdesign-binder --structure target.pdb \
33
+ --chains A --binder-length 80 --hotspots A64,A65,A66 --wait --out ./out
34
+ japanfold embed --sequence MKTAYIAK... --model esmc-600m --wait --out ./out
35
+ japanfold wait JOB_ID --out ./out # wait for a job and download it
36
+ japanfold credits # balance left on the key
37
+ japanfold jobs list
38
+ ```
39
+
40
+ `--wait` submits, waits for the job and downloads the results in one foreground command.
41
+ Without it a command prints the job id and returns; `japanfold wait JOB_ID` picks it up later
42
+ and is safe to re-run. `fold` is the same command as `predict`, and `wait` the same as `download`.
43
+
44
+ ## For agents
45
+
46
+ `--json` on any command prints machine-readable JSON on stdout and keeps human messages on
47
+ stderr. `japanfold schema` prints the OpenAPI 3.1 contract. Exit codes say what went wrong
48
+ without parsing text:
49
+
50
+ | Code | Meaning |
51
+ |---|---|
52
+ | 0 | success |
53
+ | 1 | job failed or generic error |
54
+ | 2 | missing or invalid key |
55
+ | 3 | invalid input |
56
+ | 4 | not found |
57
+ | 5 | rate-limited or at capacity |
58
+ | 6 | server unreachable |
59
+
60
+ Agents that prefer plain HTTP can skip the CLI: the [docs](https://japanfold.aiand.com/docs/)
61
+ cover the REST API, and the agent skill lives at
62
+ [github.com/japanfold/japanfold](https://github.com/japanfold/japanfold).
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: japanfold
3
+ Version: 1.0.0
4
+ Summary: Command-line client for JapanFold: protein structure prediction, binder design and embeddings over an HTTP API.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://japanfold.aiand.com
7
+ Project-URL: Documentation, https://japanfold.aiand.com/docs
8
+ Keywords: protein,structure-prediction,boltz,esmfold,binder-design,bioinformatics
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # japanfold
13
+
14
+ Command-line client for [JapanFold](https://japanfold.aiand.com), a hosted API for protein
15
+ structure prediction, binder design and protein embeddings. No GPU and no model weights on your
16
+ side; the models run on JapanFold's servers in Japan.
17
+
18
+ ```bash
19
+ pip install japanfold
20
+ export JAPANFOLD_API_KEY=jf_live_... # from https://japanfold.aiand.com/account/token
21
+ japanfold fold --sequence MKTAYIAKQRQISFVKSHFSRQ --wait --out ./out
22
+ ```
23
+
24
+ It has no dependencies beyond the Python standard library (3.9+).
25
+
26
+ ## Key and endpoint
27
+
28
+ Every call needs a key. Sign in at [japanfold.aiand.com/account/token](https://japanfold.aiand.com/account/token)
29
+ to get one; a new account comes with $100 of credit. The CLI reads it from `--api-key`, then
30
+ `$JAPANFOLD_API_KEY`, then `~/.config/japanfold/config.json` (written by `japanfold auth login`).
31
+
32
+ The API lives at `https://api.japanfold.aiand.com`. Set `JAPANFOLD_BASE_URL` or `--base-url` to
33
+ point somewhere else.
34
+
35
+ ## Commands
36
+
37
+ ```bash
38
+ japanfold models # every model and design protocol
39
+ japanfold fold --sequence MKTAYIAK... --model boltz2 --wait --out ./out
40
+ japanfold design spec.yaml --model boltzgen --protocol nanobody-anything --wait --out ./out
41
+ japanfold design --model rfd3 --protocol rfd3-binder --structure target.pdb \
42
+ --contig 'A1-150,60-80' --wait --out ./out
43
+ japanfold design --model pxdesign --protocol pxdesign-binder --structure target.pdb \
44
+ --chains A --binder-length 80 --hotspots A64,A65,A66 --wait --out ./out
45
+ japanfold embed --sequence MKTAYIAK... --model esmc-600m --wait --out ./out
46
+ japanfold wait JOB_ID --out ./out # wait for a job and download it
47
+ japanfold credits # balance left on the key
48
+ japanfold jobs list
49
+ ```
50
+
51
+ `--wait` submits, waits for the job and downloads the results in one foreground command.
52
+ Without it a command prints the job id and returns; `japanfold wait JOB_ID` picks it up later
53
+ and is safe to re-run. `fold` is the same command as `predict`, and `wait` the same as `download`.
54
+
55
+ ## For agents
56
+
57
+ `--json` on any command prints machine-readable JSON on stdout and keeps human messages on
58
+ stderr. `japanfold schema` prints the OpenAPI 3.1 contract. Exit codes say what went wrong
59
+ without parsing text:
60
+
61
+ | Code | Meaning |
62
+ |---|---|
63
+ | 0 | success |
64
+ | 1 | job failed or generic error |
65
+ | 2 | missing or invalid key |
66
+ | 3 | invalid input |
67
+ | 4 | not found |
68
+ | 5 | rate-limited or at capacity |
69
+ | 6 | server unreachable |
70
+
71
+ Agents that prefer plain HTTP can skip the CLI: the [docs](https://japanfold.aiand.com/docs/)
72
+ cover the REST API, and the agent skill lives at
73
+ [github.com/japanfold/japanfold](https://github.com/japanfold/japanfold).
@@ -0,0 +1,8 @@
1
+ README.md
2
+ japanfold_cli.py
3
+ pyproject.toml
4
+ japanfold.egg-info/PKG-INFO
5
+ japanfold.egg-info/SOURCES.txt
6
+ japanfold.egg-info/dependency_links.txt
7
+ japanfold.egg-info/entry_points.txt
8
+ japanfold.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ japanfold = japanfold_cli:main
@@ -0,0 +1 @@
1
+ japanfold_cli
@@ -0,0 +1,752 @@
1
+ #!/usr/bin/env python3
2
+ """JapanFold CLI: biomolecular structure prediction and binder design.
3
+
4
+ A dependency-free (stdlib-only) client for the JapanFold ``/v1`` API. Designed
5
+ to be driven both by humans and by coding agents (Claude Code, Codex, Cursor,
6
+ Gemini CLI): every command takes ``--json`` for machine output, auth comes from
7
+ an env var for sandboxed agents, and ``--wait`` / ``wait`` run as a single
8
+ foreground command (submit, poll, download), so an agent never has to
9
+ background a process.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import io
16
+ import json
17
+ import math
18
+ import os
19
+ import shutil
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ import zipfile
25
+ from pathlib import Path
26
+
27
+ __version__ = "1.0.0"
28
+
29
+ DEFAULT_BASE_URL = "https://api.japanfold.aiand.com"
30
+ CONFIG_PATH = Path(os.environ.get("JAPANFOLD_CONFIG",
31
+ Path.home() / ".config" / "japanfold" / "config.json"))
32
+ TERMINAL = {"succeeded", "failed", "canceled"}
33
+
34
+
35
+ # --------------------------------------------------------------------------- #
36
+ # Config / auth
37
+ # --------------------------------------------------------------------------- #
38
+ _config_cache: dict | None = None
39
+
40
+
41
+ def _load_config() -> dict:
42
+ # Read once per process: resolve_api_key + resolve_base_url both consult it
43
+ # for a single command. _save_config invalidates it.
44
+ global _config_cache
45
+ if _config_cache is None:
46
+ try:
47
+ _config_cache = json.loads(CONFIG_PATH.read_text())
48
+ except (OSError, ValueError):
49
+ _config_cache = {}
50
+ return _config_cache
51
+
52
+
53
+ def _save_config(cfg: dict) -> None:
54
+ global _config_cache
55
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
56
+ tmp = CONFIG_PATH.with_suffix(".tmp")
57
+ tmp.write_text(json.dumps(cfg, indent=2))
58
+ tmp.chmod(0o600)
59
+ os.replace(tmp, CONFIG_PATH)
60
+ _config_cache = None # next read reflects the write
61
+
62
+
63
+ def resolve_api_key(args) -> str | None:
64
+ return getattr(args, "api_key", None) or os.environ.get("JAPANFOLD_API_KEY") \
65
+ or _load_config().get("api_key")
66
+
67
+
68
+ def resolve_base_url(args) -> str:
69
+ return (getattr(args, "base_url", None) or os.environ.get("JAPANFOLD_BASE_URL")
70
+ or _load_config().get("base_url") or DEFAULT_BASE_URL).rstrip("/")
71
+
72
+
73
+ # --------------------------------------------------------------------------- #
74
+ # HTTP
75
+ # --------------------------------------------------------------------------- #
76
+ # Meaningful exit codes so an agent can branch on the failure mode without
77
+ # parsing stderr. 0=ok; 1=job failed / generic; 2=auth; 3=invalid input/usage;
78
+ # 4=not found; 5=rate-limited/capacity; 6=unreachable; 130=interrupted.
79
+ EXIT_OK, EXIT_ERROR, EXIT_AUTH, EXIT_INPUT, EXIT_NOTFOUND, EXIT_RATE, EXIT_UNREACHABLE = 0, 1, 2, 3, 4, 5, 6
80
+ _STATUS_EXIT = {0: EXIT_UNREACHABLE, 400: EXIT_INPUT, 401: EXIT_AUTH, 403: EXIT_AUTH,
81
+ 404: EXIT_NOTFOUND, 422: EXIT_INPUT, 429: EXIT_RATE}
82
+
83
+
84
+ class Done(Exception):
85
+ """Raised by a handler to exit with a specific code (e.g. a failed job)."""
86
+ def __init__(self, code):
87
+ self.code = code
88
+
89
+
90
+ class ApiError(Exception):
91
+ def __init__(self, status, body):
92
+ self.status = status
93
+ self.body = body
94
+ if isinstance(body, dict):
95
+ msg = body.get("title") or body.get("error") or str(body)
96
+ else:
97
+ msg = str(body)
98
+ detail = body.get("detail") if isinstance(body, dict) else None
99
+ super().__init__(f"{status}: {msg}" + (f": {detail}" if detail else ""))
100
+
101
+ @property
102
+ def exit_code(self):
103
+ return _STATUS_EXIT.get(self.status, EXIT_ERROR)
104
+
105
+
106
+ # A results bundle is the one response whose size is unbounded by the API, so it
107
+ # does not share the 120 s default. 900 s covers the 475 MB bundle measured on the
108
+ # service over a link as slow as 0.5 MB/s.
109
+ _ARCHIVE_TIMEOUT = 900.0
110
+
111
+
112
+ class _KeyStaysOnItsHost(urllib.request.HTTPRedirectHandler):
113
+ """Drop the key when a redirect leaves the API's host.
114
+
115
+ urllib copies every header onto the next hop, so without this a redirect to
116
+ storage (an artifact's short-lived link) would carry the customer's key to
117
+ another host, which then refuses a request with two credentials. curl,
118
+ requests and httpx already behave this way.
119
+ """
120
+
121
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
122
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
123
+ if new is not None and new.host != req.host:
124
+ new.remove_header("Authorization")
125
+ return new
126
+
127
+
128
+ _open = urllib.request.build_opener(_KeyStaysOnItsHost).open
129
+
130
+
131
+ def _request(method, base, path, key, *, body=None, raw=False, timeout=120,
132
+ idempotency=None, sink=None):
133
+ url = base + path
134
+ data = json.dumps(body).encode() if body is not None else None
135
+ req = urllib.request.Request(url, data=data, method=method)
136
+ # A real User-Agent: urllib's default ("Python-urllib/x.y") trips edge bot
137
+ # filters (e.g. Cloudflare 1010) that a named client clears.
138
+ req.add_header("User-Agent", f"japanfold-cli/{__version__}")
139
+ if key:
140
+ req.add_header("Authorization", f"Bearer {key}")
141
+ if data is not None:
142
+ req.add_header("Content-Type", "application/json")
143
+ if idempotency:
144
+ req.add_header("Idempotency-Key", idempotency)
145
+ try:
146
+ with _open(req, timeout=timeout) as resp:
147
+ if sink is not None:
148
+ # Stream to disk: a large bundle must not have to fit in memory.
149
+ sink.parent.mkdir(parents=True, exist_ok=True)
150
+ with open(sink, "wb") as fh:
151
+ shutil.copyfileobj(resp, fh, 1 << 20)
152
+ return resp.status, sink, dict(resp.headers)
153
+ payload = resp.read()
154
+ if raw:
155
+ return resp.status, payload, dict(resp.headers)
156
+ return resp.status, (json.loads(payload) if payload else {}), dict(resp.headers)
157
+ except urllib.error.HTTPError as e:
158
+ payload = e.read()
159
+ try:
160
+ parsed = json.loads(payload)
161
+ except ValueError:
162
+ parsed = {"title": payload.decode(errors="replace")[:200] or e.reason, "status": e.code}
163
+ raise ApiError(e.code, parsed) from None
164
+ except urllib.error.URLError as e:
165
+ raise ApiError(0, {"title": f"cannot reach {base}", "detail": str(e.reason)}) from None
166
+
167
+
168
+ # --------------------------------------------------------------------------- #
169
+ # Output helpers
170
+ # --------------------------------------------------------------------------- #
171
+ def _emit(obj, as_json):
172
+ if as_json:
173
+ print(json.dumps(obj, indent=2))
174
+
175
+
176
+ def _err(msg):
177
+ print(f"japanfold: {msg}", file=sys.stderr)
178
+
179
+
180
+ def _progress(job):
181
+ p = job.get("progress")
182
+ bar = f"{int(p * 100):3d}%" if isinstance(p, (int, float)) else " · "
183
+ stage = job.get("stage") or ""
184
+ return f"[{bar}] {job.get('status'):9} {stage}".rstrip()
185
+
186
+
187
+ # --------------------------------------------------------------------------- #
188
+ # Input normalization
189
+ # --------------------------------------------------------------------------- #
190
+ def _read_input(positional, sequence, inp):
191
+ """Return a request field dict: {sequence|input|...}. Precedence: --sequence,
192
+ --input, then the positional (a file path or '-' for stdin)."""
193
+ if sequence:
194
+ return {"sequence": sequence}
195
+ if inp:
196
+ return {"input": inp}
197
+ if positional:
198
+ if positional == "-" or Path(positional).is_file():
199
+ text = sys.stdin.read() if positional == "-" else Path(positional).read_text()
200
+ # A file or pipe holding residues alone is a sequence, not a FASTA/YAML
201
+ # document: the API refuses bare residues sent as 'input'.
202
+ if text.strip() and "".join(text.split()).isalpha():
203
+ return {"sequence": "".join(text.split())}
204
+ return {"input": text}
205
+ # Not a file: treat a bare alphabetic token as a sequence, else raw input.
206
+ if positional.replace("\n", "").isalpha():
207
+ return {"sequence": positional}
208
+ return {"input": positional}
209
+ return {}
210
+
211
+
212
+ def _read_text_arg(value):
213
+ """A flag that accepts a file path, '-' for stdin, or the text itself."""
214
+ if not value:
215
+ return value
216
+ if value == "-":
217
+ return sys.stdin.read()
218
+ p = Path(value)
219
+ if p.is_file():
220
+ return p.read_text()
221
+ return value
222
+
223
+
224
+ def _collect_params(pairs, **explicit):
225
+ params = {}
226
+ for k, v in explicit.items():
227
+ if v is not None:
228
+ params[k] = v
229
+ for pair in pairs or []:
230
+ if "=" not in pair:
231
+ raise SystemExit(f"--param must be key=value, got: {pair}")
232
+ k, v = pair.split("=", 1)
233
+ # coerce simple scalars
234
+ if v.lower() in ("true", "false"):
235
+ v = v.lower() == "true"
236
+ else:
237
+ try:
238
+ v = int(v)
239
+ except ValueError:
240
+ try:
241
+ v = float(v)
242
+ except ValueError:
243
+ pass
244
+ params[k] = v
245
+ return params
246
+
247
+
248
+ # --------------------------------------------------------------------------- #
249
+ # Poll + download
250
+ # --------------------------------------------------------------------------- #
251
+ def _poll_until_done(base, key, job_id, *, quiet=False, interval=5.0, max_interval=15.0):
252
+ last = None
253
+ while True:
254
+ _, job, _ = _request("GET", base, f"/v1/jobs/{job_id}", key)
255
+ line = _progress(job)
256
+ if not quiet and line != last:
257
+ print(f" {job_id[:8]} {line}", file=sys.stderr)
258
+ last = line
259
+ if job.get("status") in TERMINAL:
260
+ return job
261
+ time.sleep(interval)
262
+ interval = min(max_interval, interval * 1.3)
263
+
264
+
265
+ def _download(base, key, job_id, out_dir, *, name=None):
266
+ """Fetch the results bundle and extract it. Resume-friendly: skips if already
267
+ extracted. Returns the output directory path."""
268
+ dest = Path(out_dir) / (name or job_id)
269
+ marker = dest / ".japanfold-complete"
270
+ if marker.exists():
271
+ return dest
272
+ # A results bundle can be hundreds of MB (475 MB measured on the service), so
273
+ # the archive gets its own long timeout and lands on disk rather than in
274
+ # memory. The shared 120 s default turned a slow link into the misleading
275
+ # "no results to download".
276
+ tmp = dest.with_suffix(".zip.part")
277
+ try:
278
+ _, blob, _ = _request("GET", base, f"/v1/jobs/{job_id}/archive", key, raw=True,
279
+ timeout=_ARCHIVE_TIMEOUT, sink=tmp)
280
+ except ApiError as e:
281
+ tmp.unlink(missing_ok=True)
282
+ raise SystemExit(f"no results to download: {e}")
283
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
284
+ tmp.unlink(missing_ok=True)
285
+ raise SystemExit(f"results download failed after {_ARCHIVE_TIMEOUT:.0f}s: {e}")
286
+ dest.mkdir(parents=True, exist_ok=True)
287
+ with zipfile.ZipFile(blob) as z:
288
+ for m in z.namelist():
289
+ # Skip engine bookkeeping files; keep structures + results.json only.
290
+ if m.endswith((".lock", ".tmp", ".bak")):
291
+ continue
292
+ z.extract(m, dest)
293
+ tmp.unlink(missing_ok=True)
294
+ # Persist the final job record + the /v1 results manifest under distinct names
295
+ # so they never clobber the engine's own results.json inside the bundle.
296
+ _, job, _ = _request("GET", base, f"/v1/jobs/{job_id}", key)
297
+ (dest / "job.json").write_text(json.dumps(job, indent=2))
298
+ try:
299
+ _, res, _ = _request("GET", base, f"/v1/jobs/{job_id}/results", key)
300
+ (dest / "japanfold_manifest.json").write_text(json.dumps(res, indent=2))
301
+ except ApiError:
302
+ pass
303
+ marker.write_text("ok")
304
+ return dest
305
+
306
+
307
+ # --------------------------------------------------------------------------- #
308
+ # Commands
309
+ # --------------------------------------------------------------------------- #
310
+ def _money(amount: float, currency: str = "usd") -> str:
311
+ """An amount the way the service renders it: two significant digits, never
312
+ fewer than two decimals and never more than six.
313
+
314
+ Six is one micro, where the ledger itself stops, so nothing non-zero reads
315
+ as $0.00 -- and sub-cent is ordinary here, because a twenty-second fold
316
+ costs a fraction of a cent. The rule lives in ``japanfold/pricing.py``; this
317
+ file ships standalone with no dependencies, so it carries the five lines instead.
318
+ """
319
+ a = abs(amount)
320
+ digits = 2 if a == 0 else max(2, min(6, 1 - math.floor(math.log10(a))))
321
+ body, sign = f"{a:,.{digits}f}", "-" if amount < 0 else ""
322
+ return f"{sign}${body}" if currency.lower() == "usd" else f"{sign}{body} {currency.upper()}"
323
+
324
+
325
+ TOKEN_PAGE = "https://japanfold.aiand.com/account/token"
326
+
327
+
328
+ def _account_line(acct: dict) -> str:
329
+ """The balance, in the account's own currency, and what an hour costs."""
330
+ cur = acct.get("currency") or "usd"
331
+ per_hour = (acct.get("rate") or {}).get("per_hour")
332
+ rate = f" at {_money(per_hour, cur)} per processor hour" if per_hour else ""
333
+ return f"{_money(acct.get('balance') or 0, cur)} left{rate}."
334
+
335
+
336
+ def cmd_auth(args):
337
+ if args.auth_cmd == "login":
338
+ key = args.api_key or os.environ.get("JAPANFOLD_API_KEY")
339
+ if not key and sys.stdin.isatty():
340
+ key = input(f"API key from {TOKEN_PAGE}: ").strip()
341
+ if not key:
342
+ _err(f"no key provided (pass --api-key, set JAPANFOLD_API_KEY, or run interactively). "
343
+ f"Get one at {TOKEN_PAGE}")
344
+ return EXIT_AUTH
345
+ base = resolve_base_url(args)
346
+ try:
347
+ _request("GET", base, "/v1/jobs?limit=1", key)
348
+ except ApiError as e:
349
+ _err(f"key rejected by {base}: {e}")
350
+ return EXIT_AUTH
351
+ cfg = _load_config()
352
+ cfg["api_key"] = key
353
+ cfg["base_url"] = base
354
+ _save_config(cfg)
355
+ print(f"Logged in to {base}. Credentials saved to {CONFIG_PATH}.")
356
+ return 0
357
+ if args.auth_cmd == "status":
358
+ key = resolve_api_key(args)
359
+ base = resolve_base_url(args)
360
+ if not key:
361
+ _err(f"no key. Get one at {TOKEN_PAGE}, then `japanfold auth login`.")
362
+ return EXIT_AUTH
363
+ try:
364
+ _, acct, _ = _request("GET", base, "/v1/account", key)
365
+ except ApiError as e:
366
+ print(f"Key present but not valid at {base}: {e}")
367
+ return EXIT_AUTH
368
+ if args.json:
369
+ _emit(acct, True)
370
+ return EXIT_OK
371
+ print(f"{base} (key {key[:12]}…)")
372
+ print(_account_line(acct))
373
+ return EXIT_OK
374
+ if args.auth_cmd == "logout":
375
+ cfg = _load_config()
376
+ cfg.pop("api_key", None)
377
+ _save_config(cfg)
378
+ print("Logged out.")
379
+ return EXIT_OK
380
+ return EXIT_INPUT
381
+
382
+
383
+ def cmd_models(args):
384
+ base = resolve_base_url(args)
385
+ _, data, _ = _request("GET", base, "/v1/models", resolve_api_key(args))
386
+ if args.json:
387
+ _emit(data, True)
388
+ return 0
389
+ # A family card carries its variants in `checkpoints`, and those are the ids
390
+ # you actually pass to --model. Printing cards alone hid esmfold2-fast,
391
+ # opendde-abag and every ESMC/SaProt size except the first, so `japanfold
392
+ # models` did not list esmc-600m -- the default `japanfold embed --help`
393
+ # tells you to look here for. Nest them, the way design protocols already are.
394
+ def print_family(m):
395
+ cps = m.get("checkpoints") or []
396
+ if not cps:
397
+ print(f" {m['id']:16} {m.get('tagline', '')}")
398
+ return
399
+ # The card is named after its first checkpoint, so printing the card id
400
+ # and then the checkpoints would print one id twice. Head the group with
401
+ # the family name and indent every id under it -- what you pass --model
402
+ # is always the indented thing.
403
+ print(f" {m.get('name', m['id']):16} {m.get('tagline', '')}")
404
+ for cp in cps:
405
+ print(f" {cp['id']:24} {cp.get('name', cp.get('label', ''))}")
406
+
407
+ print("Models:")
408
+ for m in data["models"]:
409
+ print_family(m)
410
+ # Grouped by design model, because that is the choice a user makes first:
411
+ # the protocol only exists inside one of the three models.
412
+ print("Design models and their protocols:")
413
+ dms = data.get("design_models") or [{"id": e, "name": e} for e in
414
+ dict.fromkeys(p.get("engine") for p in data["protocols"])]
415
+ for dm in dms:
416
+ tag = dm.get("tagline", "")
417
+ print(f" {dm['id']:12} {dm.get('name', dm['id'])}{' ' + tag if tag else ''}")
418
+ for p in data["protocols"]:
419
+ if p.get("engine") == dm["id"]:
420
+ print(f" {p['id']:24} {p.get('name', '')}")
421
+ print("Embedding models:")
422
+ for m in data.get("embed_models", []):
423
+ print_family(m)
424
+ return 0
425
+
426
+
427
+ def cmd_schema(args):
428
+ """Print the OpenAPI 3.1 contract (agent/tool introspection)."""
429
+ base = resolve_base_url(args)
430
+ _, spec, _ = _request("GET", base, "/v1/openapi.json", None)
431
+ print(json.dumps(spec, indent=2))
432
+ return 0
433
+
434
+
435
+ def cmd_predict(args):
436
+ base, key = resolve_base_url(args), resolve_api_key(args)
437
+ fields = _read_input(args.input_pos, args.sequence, args.input)
438
+ if not fields:
439
+ _err("provide an input: a FASTA/YAML file, '-', --sequence, or --input")
440
+ return EXIT_INPUT
441
+ params = _collect_params(args.param, use_msa_server=args.use_msa_server,
442
+ fast=args.fast,
443
+ recycling_steps=args.recycling_steps,
444
+ sampling_steps=args.sampling_steps,
445
+ diffusion_samples=args.diffusion_samples,
446
+ output_format=args.output_format,
447
+ seed=args.seed, write_pae=args.write_pae,
448
+ use_tfg_guidance=args.use_tfg_guidance)
449
+ body = {"model": args.model, "name": args.name, "params": params, **fields}
450
+ _, job, _ = _request("POST", base, "/v1/predictions", key, body=body,
451
+ idempotency=args.idempotency_key)
452
+ return _after_submit(base, key, job, args)
453
+
454
+
455
+ def cmd_design(args):
456
+ base, key = resolve_base_url(args), resolve_api_key(args)
457
+ params = _collect_params(args.param, num_designs=args.num_designs,
458
+ budget=args.budget, num_timesteps=args.num_timesteps,
459
+ n_step=args.n_step, fast=args.fast, seed=args.seed)
460
+ body = {"model": args.model, "protocol": args.protocol, "name": args.name, "params": params}
461
+ # Three input shapes, one per design model: BoltzGen takes a YAML spec,
462
+ # RFdiffusion3 a target structure plus a contig, PXDesign a target structure plus
463
+ # the chains to condition on. Route on what the caller supplied; the server is the
464
+ # authority on which the protocol needs.
465
+ if args.structure or args.contig or args.chains or args.binder_length:
466
+ body["structure"] = _read_text_arg(args.structure)
467
+ for key, val in (("contig", args.contig), ("chains", args.chains),
468
+ ("binder_length", args.binder_length), ("hotspots", args.hotspots)):
469
+ if val is not None:
470
+ body[key] = val
471
+ else:
472
+ spec = _read_input(args.input_pos, None, args.spec).get("input")
473
+ if not spec:
474
+ _err("provide a design spec (a YAML file, '-', or --spec), "
475
+ "--structure and --contig for an RFdiffusion3 protocol, or "
476
+ "--structure, --chains and --binder-length for a PXDesign one")
477
+ return EXIT_INPUT
478
+ body["spec"] = spec
479
+ _, job, _ = _request("POST", base, "/v1/designs", key, body=body)
480
+ return _after_submit(base, key, job, args)
481
+
482
+
483
+ def cmd_embed(args):
484
+ base, key = resolve_base_url(args), resolve_api_key(args)
485
+ fields = _read_input(args.input_pos, args.sequence, args.input)
486
+ if not fields:
487
+ _err("provide an input: a FASTA/YAML file, '-', --sequence, or --input")
488
+ return EXIT_INPUT
489
+ params = _collect_params(args.param, pool=args.pool, format=args.format,
490
+ fast=args.fast)
491
+ body = {"model": args.model, "name": args.name, "params": params, **fields}
492
+ _, job, _ = _request("POST", base, "/v1/embeddings", key, body=body,
493
+ idempotency=args.idempotency_key)
494
+ return _after_submit(base, key, job, args)
495
+
496
+
497
+ def _after_submit(base, key, job, args):
498
+ job_id = job["id"]
499
+ _err(f"Submitted job {job_id} ({job.get('kind')}, {job.get('model') or job.get('protocol')})")
500
+ if not args.wait:
501
+ _emit(job, args.json)
502
+ if not args.json:
503
+ print(job_id)
504
+ return 0
505
+ return _finish(base, key, job_id, args)
506
+
507
+
508
+ def _finish(base, key, job_id, args):
509
+ """Poll a job to completion, download its results, and emit. Shared by
510
+ ``predict/design --wait`` and the standalone ``download`` command."""
511
+ final = _poll_until_done(base, key, job_id, quiet=args.json)
512
+ if final.get("status") != "succeeded":
513
+ _err(f"job {final.get('status')}: {final.get('error') or 'see logs'}")
514
+ _emit(final, args.json)
515
+ raise Done(1)
516
+ dest = _download(base, key, job_id, args.out or ".", name=args.name)
517
+ _err(f"Done. Results in {dest}")
518
+ _emit({**final, "output_dir": str(dest)}, args.json)
519
+ if not args.json:
520
+ print(str(dest))
521
+ return 0
522
+
523
+
524
+ def cmd_jobs(args):
525
+ base, key = resolve_base_url(args), resolve_api_key(args)
526
+ if args.jobs_cmd == "list":
527
+ path = f"/v1/jobs?limit={args.limit}" + (f"&cursor={args.cursor}" if args.cursor else "")
528
+ _, data, _ = _request("GET", base, path, key)
529
+ if args.json:
530
+ _emit(data, True)
531
+ return 0
532
+ for j in data["data"]:
533
+ print(f" {j['id']} {j['status']:9} {j.get('kind'):7} {j.get('name', '')}")
534
+ if data.get("has_more"):
535
+ print(f" … more (next cursor: {data['next_cursor']})", file=sys.stderr)
536
+ return 0
537
+ if args.jobs_cmd == "get":
538
+ _, job, _ = _request("GET", base, f"/v1/jobs/{args.job_id}", key)
539
+ if args.json:
540
+ _emit(job, True)
541
+ else:
542
+ print(f"{job['id']} {_progress(job)}")
543
+ return 0 if job.get("status") != "failed" else 1
544
+ if args.jobs_cmd == "cancel":
545
+ _, res, _ = _request("POST", base, f"/v1/jobs/{args.job_id}/cancel", key)
546
+ _emit(res, args.json)
547
+ if not args.json:
548
+ print("canceled" if res.get("canceled") else "not canceled")
549
+ return 0
550
+ return EXIT_INPUT
551
+
552
+
553
+ def cmd_credits(args):
554
+ args.auth_cmd = "status"
555
+ return cmd_auth(args)
556
+
557
+
558
+ def cmd_download(args):
559
+ base, key = resolve_base_url(args), resolve_api_key(args)
560
+ return _finish(base, key, args.job_id, args)
561
+
562
+
563
+ def cmd_logs(args):
564
+ base, key = resolve_base_url(args), resolve_api_key(args)
565
+ _, blob, _ = _request("GET", base, f"/v1/jobs/{args.job_id}/logs", key, raw=True)
566
+ sys.stdout.write(blob.decode(errors="replace"))
567
+ return 0
568
+
569
+
570
+ # --------------------------------------------------------------------------- #
571
+ # Argument parsing
572
+ # --------------------------------------------------------------------------- #
573
+ EPILOG = f"""\
574
+ key: JAPANFOLD_API_KEY (or --api-key, or `japanfold auth login`).
575
+ Get one at {TOKEN_PAGE}
576
+ base URL: {DEFAULT_BASE_URL} (override with JAPANFOLD_BASE_URL or --base-url)
577
+
578
+ examples:
579
+ japanfold fold --sequence MKTAYIAKQR... --wait --out ./out
580
+ japanfold design spec.yaml --model boltzgen --protocol nanobody-anything --wait --out ./out
581
+ japanfold embed --sequence MKTAYIAKQR... --wait --out ./out
582
+ japanfold wait JOB_ID --out ./out
583
+ japanfold credits
584
+
585
+ `japanfold models` lists every model and design protocol; `japanfold <command> -h`
586
+ shows its flags. Exit codes: 0 ok, 1 job failed, 2 auth, 3 bad input, 4 not found,
587
+ 5 rate-limited, 6 unreachable.
588
+ """
589
+
590
+ def build_parser():
591
+ # Global flags live on a parent parser (with SUPPRESS defaults) added to the
592
+ # top parser AND every subparser, so `--json`/`--base-url`/`--api-key` work in
593
+ # any position, before OR after the subcommand, without clobbering.
594
+ common = argparse.ArgumentParser(add_help=False)
595
+ common.add_argument("--base-url", default=argparse.SUPPRESS,
596
+ help=f"API base URL (default {DEFAULT_BASE_URL}).")
597
+ common.add_argument("--api-key", default=argparse.SUPPRESS, help="Override the API key.")
598
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
599
+ help="Machine-readable JSON on stdout.")
600
+
601
+ def add(subparsers, name, **kw):
602
+ return subparsers.add_parser(name, parents=[common], **kw)
603
+
604
+ p = argparse.ArgumentParser(prog="japanfold", parents=[common],
605
+ description=__doc__.splitlines()[0], epilog=EPILOG,
606
+ formatter_class=argparse.RawDescriptionHelpFormatter)
607
+ p.add_argument("--version", action="version", version=f"japanfold {__version__}")
608
+ sub = p.add_subparsers(dest="cmd", required=True)
609
+
610
+ a = add(sub, "auth", help="Save a key / check the balance / log out.")
611
+ asub = a.add_subparsers(dest="auth_cmd", required=True)
612
+ add(asub, "login")
613
+ add(asub, "status")
614
+ add(asub, "logout")
615
+
616
+ add(sub, "models", help="List available models, protocols and limits.")
617
+ add(sub, "schema", help="Print the OpenAPI 3.1 contract (for tooling/agents).")
618
+
619
+ add(sub, "credits", help="Show the balance left on your key (same as `auth status`).")
620
+
621
+ pr = add(sub, "predict", aliases=["fold"], help="Predict a structure.")
622
+ pr.add_argument("input_pos", nargs="?", metavar="INPUT", help="FASTA/YAML file, or '-' for stdin.")
623
+ pr.add_argument("--sequence", help="A single protein sequence (convenience).")
624
+ pr.add_argument("--input", help="Inline FASTA/YAML string.")
625
+ pr.add_argument("--model", default="boltz2",
626
+ help="Prediction model id (default boltz2). `japanfold models` lists them.")
627
+ pr.add_argument("--name")
628
+ # Both directions, or the CLI can only ever agree with the server default:
629
+ # --use-msa-server had no opposite, so a CLI user could not fold Protenix-v2 /
630
+ # OpenFold3 / OpenBind-0 / RoseTTAFold3 / OpenDDE single-sequence at all,
631
+ # though the web form and the raw API both offer it. Same for --fast on all
632
+ # three verbs. Unset stays absent from the body, which is what makes an
633
+ # omitted param take the catalog default.
634
+ pr.add_argument("--fast", action=argparse.BooleanOptionalAction, default=None,
635
+ help="Higher throughput, may be slightly less accurate. Off unless given.")
636
+ pr.add_argument("--use-msa-server", action=argparse.BooleanOptionalAction,
637
+ default=None, dest="use_msa_server",
638
+ help="Build an MSA for the input. --no-use-msa-server folds single-sequence.")
639
+ pr.add_argument("--recycling-steps", type=int, dest="recycling_steps",
640
+ help="Omit it and the model uses its own recommended value.")
641
+ pr.add_argument("--sampling-steps", type=int, dest="sampling_steps",
642
+ help="Omit it and the model uses its own recommended value.")
643
+ pr.add_argument("--diffusion-samples", type=int, dest="diffusion_samples",
644
+ help="Structures to sample per target.")
645
+ pr.add_argument("--output-format", choices=["cif", "pdb"], dest="output_format")
646
+ pr.add_argument("--seed", type=int, help="Diffusion noise seed. Default 0.")
647
+ pr.add_argument("--write-pae", action=argparse.BooleanOptionalAction, default=None,
648
+ dest="write_pae",
649
+ help="Also return the PAE matrix (models with the pae cap).")
650
+ pr.add_argument("--use-tfg-guidance", action=argparse.BooleanOptionalAction, default=None,
651
+ dest="use_tfg_guidance",
652
+ help="OpenDDE: steer sampling with the input's constraint: block "
653
+ "(contact pairs or epitope residues).")
654
+ pr.add_argument("--param", action="append", help="Extra param key=value (repeatable).")
655
+ pr.add_argument("--idempotency-key", dest="idempotency_key")
656
+ pr.add_argument("--wait", action="store_true", help="Poll to completion and download results.")
657
+ pr.add_argument("--out", help="Output directory for --wait (default: cwd).")
658
+
659
+ de = add(sub, "design",
660
+ help="Design binders/proteins (BoltzGen, RFdiffusion3 or PXDesign).")
661
+ de.add_argument("input_pos", nargs="?", metavar="SPEC", help="YAML design spec file, or '-'.")
662
+ de.add_argument("--spec", help="Inline YAML design spec (BoltzGen protocols).")
663
+ de.add_argument("--model", dest="model",
664
+ help="Design model: boltzgen, rfd3 or pxdesign. Must match the protocol; "
665
+ "`japanfold models` groups the protocols by model.")
666
+ de.add_argument("--protocol", help="Design protocol id. `japanfold models` lists them.")
667
+ de.add_argument("--structure", help="Target structure (PDB/mmCIF file, '-' for stdin, or the "
668
+ "text itself). RFdiffusion3 and PXDesign protocols.")
669
+ de.add_argument("--contig", help="RFdiffusion3 contig, e.g. 'A1-150,60-80': what stays fixed "
670
+ "and what gets designed.")
671
+ de.add_argument("--chains", help="Target chain ids to design against, e.g. 'A' or 'A,B'. "
672
+ "PXDesign protocols only.")
673
+ de.add_argument("--binder-length", type=int, dest="binder_length",
674
+ help="Residues in the designed binder, 8-200 (PXDesign).")
675
+ de.add_argument("--hotspots", help="Target residues to aim the binder at, e.g. 'A64,A65,A66' "
676
+ "(bare numbers when one chain is named). PXDesign, optional.")
677
+ de.add_argument("--name")
678
+ de.add_argument("--num-designs", type=int, dest="num_designs")
679
+ de.add_argument("--budget", type=int, help="Ranked designs to keep (BoltzGen).")
680
+ de.add_argument("--num-timesteps", type=int, dest="num_timesteps",
681
+ help="Diffusion steps per design (RFdiffusion3).")
682
+ de.add_argument("--n-step", type=int, dest="n_step",
683
+ help="Diffusion steps per design (PXDesign).")
684
+ de.add_argument("--fast", action=argparse.BooleanOptionalAction, default=None,
685
+ help="Higher throughput, may be slightly less accurate. Off unless given.")
686
+ de.add_argument("--seed", type=int,
687
+ help="Noise seed: the same seed and input give the same designs. "
688
+ "BoltzGen draws fresh when omitted; RFdiffusion3 and PXDesign use 42.")
689
+ de.add_argument("--param", action="append")
690
+ de.add_argument("--wait", action="store_true")
691
+ de.add_argument("--out")
692
+
693
+ em = add(sub, "embed", help="Compute protein-language-model embeddings.")
694
+ em.add_argument("input_pos", nargs="?", metavar="INPUT", help="FASTA/YAML file, or '-' for stdin.")
695
+ em.add_argument("--sequence", help="A single protein sequence (convenience).")
696
+ em.add_argument("--input", help="Inline FASTA/YAML string.")
697
+ em.add_argument("--model", default="esmc-600m",
698
+ help="Embedding model id (default esmc-600m). `japanfold models` lists them.")
699
+ em.add_argument("--pool", default="mean", choices=["mean", "max", "cls"])
700
+ em.add_argument("--format", default="npz", choices=["npz", "parquet"], dest="format")
701
+ em.add_argument("--name")
702
+ em.add_argument("--fast", action=argparse.BooleanOptionalAction, default=None)
703
+ em.add_argument("--param", action="append", help="Extra param key=value (repeatable).")
704
+ em.add_argument("--idempotency-key", dest="idempotency_key")
705
+ em.add_argument("--wait", action="store_true", help="Poll to completion and download results.")
706
+ em.add_argument("--out", help="Output directory for --wait (default: cwd).")
707
+
708
+ j = add(sub, "jobs", help="List / inspect / cancel jobs.")
709
+ jsub = j.add_subparsers(dest="jobs_cmd", required=True)
710
+ jl = add(jsub, "list")
711
+ jl.add_argument("--limit", type=int, default=20)
712
+ jl.add_argument("--cursor")
713
+ add(jsub, "get").add_argument("job_id")
714
+ add(jsub, "cancel").add_argument("job_id")
715
+
716
+ dl = add(sub, "download", aliases=["wait"],
717
+ help="Wait for a job to finish and download its results.")
718
+ dl.add_argument("job_id")
719
+ dl.add_argument("--out")
720
+ dl.add_argument("--name", help="Subdirectory name (default: job id).")
721
+
722
+ lg = add(sub, "logs", help="Print a job's run log.")
723
+ lg.add_argument("job_id")
724
+ return p
725
+
726
+
727
+ def main(argv=None):
728
+ args = build_parser().parse_args(argv)
729
+ # Materialize SUPPRESS-defaulted globals so handlers can read them freely.
730
+ args.json = getattr(args, "json", False)
731
+ args.base_url = getattr(args, "base_url", None)
732
+ args.api_key = getattr(args, "api_key", None)
733
+ if not hasattr(args, "name"):
734
+ args.name = None
735
+ handlers = {"auth": cmd_auth, "credits": cmd_credits, "models": cmd_models,
736
+ "predict": cmd_predict, "fold": cmd_predict, "design": cmd_design,
737
+ "embed": cmd_embed, "jobs": cmd_jobs, "download": cmd_download,
738
+ "wait": cmd_download, "logs": cmd_logs, "schema": cmd_schema}
739
+ try:
740
+ return handlers[args.cmd](args) or EXIT_OK
741
+ except Done as e:
742
+ return e.code
743
+ except ApiError as e:
744
+ _err(str(e))
745
+ return e.exit_code
746
+ except KeyboardInterrupt:
747
+ _err("interrupted")
748
+ return 130
749
+
750
+
751
+ if __name__ == "__main__":
752
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "japanfold"
7
+ version = "1.0.0"
8
+ description = "Command-line client for JapanFold: protein structure prediction, binder design and embeddings over an HTTP API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ # Intentionally dependency-free (stdlib only) so it installs anywhere an agent runs.
13
+ dependencies = []
14
+ keywords = ["protein", "structure-prediction", "boltz", "esmfold", "binder-design", "bioinformatics"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://japanfold.aiand.com"
18
+ Documentation = "https://japanfold.aiand.com/docs"
19
+
20
+ [project.scripts]
21
+ japanfold = "japanfold_cli:main"
22
+
23
+ [tool.setuptools]
24
+ py-modules = ["japanfold_cli"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+