amina-cli 0.2.5__tar.gz → 0.2.7__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 (64) hide show
  1. {amina_cli-0.2.5 → amina_cli-0.2.7}/PKG-INFO +1 -1
  2. {amina_cli-0.2.5 → amina_cli-0.2.7}/pyproject.toml +1 -1
  3. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/__init__.py +1 -1
  4. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/auth.py +31 -0
  5. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/client.py +84 -4
  6. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/jobs_cmd.py +144 -27
  7. amina_cli-0.2.7/src/amina_cli/commands/tools/analysis/residue_accessibility.py +121 -0
  8. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/registry.py +11 -0
  9. {amina_cli-0.2.5 → amina_cli-0.2.7}/.gitignore +0 -0
  10. {amina_cli-0.2.5 → amina_cli-0.2.7}/LICENSE +0 -0
  11. {amina_cli-0.2.5 → amina_cli-0.2.7}/README.md +0 -0
  12. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/__init__.py +0 -0
  13. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/auth_cmd.py +0 -0
  14. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/run_cmd.py +0 -0
  15. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/__init__.py +0 -0
  16. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/analysis/__init__.py +0 -0
  17. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/analysis/hydrophobicity.py +0 -0
  18. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/analysis/mmseqs2_cluster.py +0 -0
  19. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/analysis/rmsd.py +0 -0
  20. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/analysis/sasa.py +0 -0
  21. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/analysis/simple_rmsd.py +0 -0
  22. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/analysis/surface_charge.py +0 -0
  23. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/analysis/usalign.py +0 -0
  24. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/design/__init__.py +0 -0
  25. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/design/esm_if1.py +0 -0
  26. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/design/protein_mc.py +0 -0
  27. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/design/proteinmpnn.py +0 -0
  28. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/design/rfdiffusion.py +0 -0
  29. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/display.py +0 -0
  30. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/folding/__init__.py +0 -0
  31. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/folding/boltz2.py +0 -0
  32. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/folding/esmfold.py +0 -0
  33. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/folding/openfold3.py +0 -0
  34. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/folding/protenix.py +0 -0
  35. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/__init__.py +0 -0
  36. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/autodock_vina.py +0 -0
  37. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/diffdock.py +0 -0
  38. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/dockq.py +0 -0
  39. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/emngly.py +0 -0
  40. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/glycosylation_ensemble.py +0 -0
  41. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/interface_identifier.py +0 -0
  42. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/isoglyp.py +0 -0
  43. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/lmngly.py +0 -0
  44. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/p2rank.py +0 -0
  45. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/interactions/pesto.py +0 -0
  46. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/properties/__init__.py +0 -0
  47. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/properties/aminosol.py +0 -0
  48. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/properties/esm1v.py +0 -0
  49. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/properties/esm2_embedding.py +0 -0
  50. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/__init__.py +0 -0
  51. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/activesite_verifier.py +0 -0
  52. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/chain_select.py +0 -0
  53. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/distance_calculator.py +0 -0
  54. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/maxit_convert.py +0 -0
  55. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/mol_size_calculator.py +0 -0
  56. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/obabel_convert.py +0 -0
  57. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/pdb_bfactor_overwrite.py +0 -0
  58. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/pdb_cleaner.py +0 -0
  59. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/pdb_quality_assessment.py +0 -0
  60. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/pdb_to_fasta.py +0 -0
  61. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools/utilities/protein_relaxer.py +0 -0
  62. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/commands/tools_cmd.py +0 -0
  63. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/main.py +0 -0
  64. {amina_cli-0.2.5 → amina_cli-0.2.7}/src/amina_cli/storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amina-cli
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: CLI for AminoAnalytica protein engineering platform
5
5
  Project-URL: Homepage, https://aminoanalytica.com
6
6
  Project-URL: Documentation, https://docs.aminoanalytica.com
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amina-cli"
3
- version = "0.2.5"
3
+ version = "0.2.7"
4
4
  description = "CLI for AminoAnalytica protein engineering platform"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
@@ -9,4 +9,4 @@ Quick start:
9
9
  amina run esmfold --sequence "MKFLILLFNILCLFPVLAADNH"
10
10
  """
11
11
 
12
- __version__ = "0.2.5"
12
+ __version__ = "0.2.7"
@@ -336,3 +336,34 @@ def update_job_status(job_id: str, status: str) -> None:
336
336
  flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
337
337
  with os.fdopen(os.open(JOBS_FILE, flags, 0o600), "w") as f:
338
338
  f.write(content)
339
+
340
+
341
+ def update_job_info(job_id: str, updates: dict) -> None:
342
+ """
343
+ Merge fields into a saved job entry.
344
+
345
+ Used to persist a resolved call_id so subsequent polls skip the queue check.
346
+
347
+ Args:
348
+ job_id: Job ID to update
349
+ updates: Dict of fields to merge (e.g., {"call_id": "fc-xxx"})
350
+ """
351
+ import os
352
+
353
+ if not JOBS_FILE.exists():
354
+ return
355
+
356
+ try:
357
+ jobs = json.loads(JOBS_FILE.read_text())
358
+ except (json.JSONDecodeError, IOError):
359
+ return
360
+
361
+ for job in jobs:
362
+ if job.get("job_id") == job_id:
363
+ job.update(updates)
364
+ break
365
+
366
+ content = json.dumps(jobs, indent=2)
367
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
368
+ with os.fdopen(os.open(JOBS_FILE, flags, 0o600), "w") as f:
369
+ f.write(content)
@@ -20,7 +20,13 @@ from typing import Any, Optional
20
20
  from uuid import uuid4
21
21
 
22
22
  from amina_cli.auth import get_api_key, AuthError
23
- from amina_cli.registry import get_submit_endpoint, get_status_endpoint, get_queued_status_endpoint, ToolNotFoundError
23
+ from amina_cli.registry import (
24
+ get_submit_endpoint,
25
+ get_status_endpoint,
26
+ get_queued_status_endpoint,
27
+ get_cancel_endpoint,
28
+ ToolNotFoundError,
29
+ )
24
30
 
25
31
 
26
32
  # Default polling configuration
@@ -92,10 +98,12 @@ class InsufficientCreditsError(ClientError):
92
98
  class JobLimitError(ClientError):
93
99
  """Raised when user has reached job concurrency limits."""
94
100
 
95
- def __init__(self, limit_type: str, current: int, limit: int, message: str):
101
+ def __init__(self, limit_type: str, current: int, limit: int, message: str, **kwargs: Any):
96
102
  self.limit_type = limit_type
97
103
  self.current = current
98
104
  self.limit = limit
105
+ self.running = kwargs.get("running", 0)
106
+ self.queued = kwargs.get("queued", 0)
99
107
  super().__init__(message)
100
108
 
101
109
 
@@ -185,6 +193,7 @@ async def _poll_queued_until_submitted(
185
193
  poll_interval: float = 5.0, # Longer interval for queue polling
186
194
  max_poll_interval: float = 30.0,
187
195
  on_position_update: Optional[QueuePositionCallback] = None,
196
+ headers: Optional[dict[str, str]] = None,
188
197
  ) -> tuple[str, str]:
189
198
  """
190
199
  Poll queued_job_status until job is submitted.
@@ -229,7 +238,7 @@ async def _poll_queued_until_submitted(
229
238
  status_url = f"{queued_endpoint}?job_id={job_id}"
230
239
 
231
240
  try:
232
- response = await client.get(status_url)
241
+ response = await client.get(status_url, headers=headers or {})
233
242
  except httpx.RequestError as e:
234
243
  # Log transient error and continue polling
235
244
  print(f"Queue poll network error (will retry): {type(e).__name__}: {e}")
@@ -396,6 +405,8 @@ async def run_tool(
396
405
  current=detail.get("current", 0),
397
406
  limit=detail.get("limit", 0),
398
407
  message=detail.get("message", "Job limit reached. Please wait for running jobs to complete."),
408
+ running=detail.get("running", 0),
409
+ queued=detail.get("queued", 0),
399
410
  )
400
411
  except (ValueError, KeyError):
401
412
  raise JobLimitError(
@@ -438,6 +449,7 @@ async def run_tool(
438
449
  poll_interval=5.0, # Slower for queue
439
450
  max_poll_interval=30.0,
440
451
  on_position_update=on_queue_position_update,
452
+ headers=auth_headers,
441
453
  )
442
454
  else:
443
455
  # Job was submitted immediately
@@ -663,6 +675,8 @@ async def submit_tool(
663
675
  current=detail.get("current", 0),
664
676
  limit=detail.get("limit", 0),
665
677
  message=detail.get("message", "Job limit reached. Please wait for running jobs to complete."),
678
+ running=detail.get("running", 0),
679
+ queued=detail.get("queued", 0),
666
680
  )
667
681
  except (ValueError, KeyError):
668
682
  raise JobLimitError(
@@ -855,12 +869,16 @@ async def check_queued_job_status(job_id: str) -> dict[str, Any]:
855
869
  - {"status": "submitted", "call_id": "..."} if spawned
856
870
  - {"status": "failed", "error": "..."} if failed/expired
857
871
  """
872
+ from amina_cli.auth import get_api_key
873
+
858
874
  queued_endpoint = get_queued_status_endpoint()
859
875
  status_url = f"{queued_endpoint}?job_id={job_id}"
876
+ api_key = get_api_key()
877
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
860
878
 
861
879
  async with httpx.AsyncClient(timeout=30.0) as client:
862
880
  try:
863
- response = await client.get(status_url)
881
+ response = await client.get(status_url, headers=headers)
864
882
  except httpx.RequestError as e:
865
883
  raise ToolExecutionError(f"Network error checking queued job status: {str(e)}")
866
884
 
@@ -883,6 +901,68 @@ def check_queued_job_status_sync(job_id: str) -> dict[str, Any]:
883
901
  return asyncio.run(check_queued_job_status(job_id))
884
902
 
885
903
 
904
+ async def cancel_job(job_id: str, api_key: Optional[str] = None) -> dict[str, Any]:
905
+ """
906
+ Cancel a queued or running job.
907
+
908
+ Args:
909
+ job_id: Job ID to cancel
910
+ api_key: Optional API key (uses stored key if not provided)
911
+
912
+ Returns:
913
+ Dict with status info:
914
+ - {"status": "cancelled"} on success
915
+ - {"status": "not_found"} if job doesn't exist
916
+ - {"status": "already_finished"} if job already completed/failed/cancelled
917
+
918
+ Raises:
919
+ AuthenticationError: If API key is invalid
920
+ ToolExecutionError: If request fails unexpectedly
921
+ """
922
+ if api_key is None:
923
+ try:
924
+ api_key = get_api_key()
925
+ except AuthError as e:
926
+ raise AuthenticationError(str(e))
927
+
928
+ cancel_endpoint = get_cancel_endpoint()
929
+ url = f"{cancel_endpoint}?job_id={job_id}"
930
+
931
+ async with httpx.AsyncClient(timeout=30.0) as client:
932
+ try:
933
+ response = await client.post(url, headers={"Authorization": f"Bearer {api_key}"})
934
+ except httpx.RequestError as e:
935
+ raise ToolExecutionError(f"Network error cancelling job: {str(e)}")
936
+
937
+ if response.status_code == 401:
938
+ raise AuthenticationError("Invalid API key. Get a new one at: https://app.aminoanalytica.com/settings/api")
939
+
940
+ if response.status_code == 404:
941
+ return {"status": "not_found", "error": "Job not found"}
942
+
943
+ if response.status_code == 409:
944
+ return {"status": "already_finished", "error": "Job already finished"}
945
+
946
+ if response.status_code == 200:
947
+ return response.json()
948
+
949
+ raise ToolExecutionError(f"Cancel failed with HTTP {response.status_code}: {response.text}")
950
+
951
+
952
+ def cancel_job_sync(job_id: str, api_key: Optional[str] = None) -> dict[str, Any]:
953
+ """
954
+ Synchronous wrapper for cancel_job.
955
+
956
+ Args:
957
+ job_id: Job ID to cancel
958
+ api_key: Optional API key
959
+
960
+ Returns:
961
+ Dict with status info
962
+ """
963
+ return asyncio.run(cancel_job(job_id, api_key))
964
+
965
+
886
966
  def check_endpoint_health(tool_name: str) -> bool:
887
967
  """
888
968
  Check if a tool's endpoint is reachable.
@@ -21,6 +21,65 @@ app = typer.Typer(no_args_is_help=True)
21
21
  console = Console()
22
22
 
23
23
 
24
+ def _resolve_job_status(job_info: dict) -> dict:
25
+ """
26
+ Resolve the current status of a job, handling both spawned and queued jobs.
27
+
28
+ If call_id is present, polls Modal directly. If missing (queued job),
29
+ checks the queue first and resolves call_id when available.
30
+
31
+ Args:
32
+ job_info: Full job info dict from local history
33
+
34
+ Returns:
35
+ Dict with status info (same shape as check_job_status_sync)
36
+ """
37
+ from amina_cli.client import check_job_status_sync, check_queued_job_status_sync
38
+ from amina_cli.auth import update_job_info
39
+
40
+ call_id = job_info.get("call_id", "")
41
+ job_id = job_info.get("job_id", "")
42
+
43
+ if call_id:
44
+ # Normal path: job was spawned, poll Modal directly
45
+ return check_job_status_sync(
46
+ call_id=call_id,
47
+ job_id=job_id,
48
+ user_id=job_info.get("user_id", ""),
49
+ tool_name=job_info.get("tool_name", ""),
50
+ reserved_cost=job_info.get("reserved_cost", 0.0),
51
+ compute_type=job_info.get("compute_type", "cpu"),
52
+ )
53
+
54
+ # Queued path: no call_id yet, check queue status
55
+ queue_result = check_queued_job_status_sync(job_id)
56
+ queue_status = queue_result.get("status", "")
57
+
58
+ if queue_status in ("queued", "not_found"):
59
+ return queue_result
60
+
61
+ if queue_status == "failed":
62
+ return queue_result
63
+
64
+ # Job has been spawned (submitted/running/completed) — try to get call_id
65
+ resolved_call_id = queue_result.get("call_id", "")
66
+ if not resolved_call_id:
67
+ # Race condition: status changed but call_id not yet written
68
+ return {"status": "queued", "message": "Job is being spawned"}
69
+
70
+ # Persist call_id so future polls skip the queue check
71
+ update_job_info(job_id, {"call_id": resolved_call_id})
72
+
73
+ return check_job_status_sync(
74
+ call_id=resolved_call_id,
75
+ job_id=job_id,
76
+ user_id=job_info.get("user_id", ""),
77
+ tool_name=job_info.get("tool_name", ""),
78
+ reserved_cost=job_info.get("reserved_cost", 0.0),
79
+ compute_type=job_info.get("compute_type", "cpu"),
80
+ )
81
+
82
+
24
83
  @app.command("list")
25
84
  def list_jobs(
26
85
  limit: int = typer.Option(
@@ -101,7 +160,7 @@ def status(
101
160
  amina jobs status abc123 --json
102
161
  """
103
162
  from amina_cli.auth import get_job_info
104
- from amina_cli.client import check_job_status_sync, ToolExecutionError
163
+ from amina_cli.client import ToolExecutionError
105
164
 
106
165
  results = []
107
166
 
@@ -121,14 +180,7 @@ def status(
121
180
 
122
181
  # Query server for current status
123
182
  try:
124
- status_result = check_job_status_sync(
125
- call_id=job_info.get("call_id", ""),
126
- job_id=job_info.get("job_id", job_id),
127
- user_id=job_info.get("user_id", ""),
128
- tool_name=job_info.get("tool_name", ""),
129
- reserved_cost=job_info.get("reserved_cost", 0.0),
130
- compute_type=job_info.get("compute_type", "cpu"),
131
- )
183
+ status_result = _resolve_job_status(job_info)
132
184
  results.append(
133
185
  {
134
186
  "job_id": job_info.get("job_id", job_id),
@@ -212,7 +264,7 @@ def wait(
212
264
  amina jobs wait abc123 --timeout 7200 --poll-interval 30
213
265
  """
214
266
  from amina_cli.auth import get_job_info
215
- from amina_cli.client import check_job_status_sync, ToolExecutionError
267
+ from amina_cli.client import ToolExecutionError
216
268
 
217
269
  start_time = time.time()
218
270
  pending_jobs = set(job_ids)
@@ -237,14 +289,7 @@ def wait(
237
289
  continue
238
290
 
239
291
  try:
240
- status_result = check_job_status_sync(
241
- call_id=job_info.get("call_id", ""),
242
- job_id=job_info.get("job_id", job_id),
243
- user_id=job_info.get("user_id", ""),
244
- tool_name=job_info.get("tool_name", ""),
245
- reserved_cost=job_info.get("reserved_cost", 0.0),
246
- compute_type=job_info.get("compute_type", "cpu"),
247
- )
292
+ status_result = _resolve_job_status(job_info)
248
293
 
249
294
  if status_result.get("status") in ("completed", "failed"):
250
295
  results[job_id] = {
@@ -290,6 +335,80 @@ def wait(
290
335
  raise typer.Exit(1)
291
336
 
292
337
 
338
+ @app.command("cancel")
339
+ def cancel(
340
+ job_ids: list[str] = typer.Argument(
341
+ ...,
342
+ help="Job ID(s) to cancel",
343
+ ),
344
+ json_output: bool = typer.Option(
345
+ False,
346
+ "--json",
347
+ help="Output as JSON",
348
+ ),
349
+ ):
350
+ """
351
+ Cancel one or more queued or running jobs.
352
+
353
+ Sends a cancellation request to the server. Credits are fully refunded.
354
+
355
+ Examples:
356
+ amina jobs cancel abc123
357
+ amina jobs cancel abc123 def456
358
+ amina jobs cancel abc123 --json
359
+ """
360
+ from amina_cli.auth import get_job_info, update_job_status
361
+ from amina_cli.client import cancel_job_sync, ToolExecutionError
362
+
363
+ results = []
364
+
365
+ for job_id in job_ids:
366
+ job_info = get_job_info(job_id)
367
+
368
+ if not job_info:
369
+ results.append({"job_id": job_id, "status": "not_found", "error": "Job not found in local history"})
370
+ continue
371
+
372
+ full_job_id = job_info.get("job_id", job_id)
373
+
374
+ try:
375
+ cancel_result = cancel_job_sync(full_job_id)
376
+ status = cancel_result.get("status", "error")
377
+
378
+ if status == "cancelled":
379
+ update_job_status(full_job_id, "cancelled")
380
+
381
+ results.append(
382
+ {
383
+ "job_id": full_job_id,
384
+ "tool_name": job_info.get("tool_name", ""),
385
+ **cancel_result,
386
+ }
387
+ )
388
+ except ToolExecutionError as e:
389
+ results.append({"job_id": full_job_id, "status": "error", "error": str(e)})
390
+
391
+ if json_output:
392
+ console.print(json.dumps(results, indent=2, default=str))
393
+ return
394
+
395
+ for result in results:
396
+ job_id_short = result.get("job_id", "")[:8]
397
+ status_str = result.get("status", "error")
398
+ tool_name = result.get("tool_name", "")
399
+ label = f" ({tool_name})" if tool_name else ""
400
+
401
+ if status_str == "cancelled":
402
+ console.print(f"[green]\u2713[/green] {job_id_short}...{label}: [green]cancelled[/green]")
403
+ elif status_str == "already_finished":
404
+ console.print(f"[yellow]\u2717[/yellow] {job_id_short}...{label}: [yellow]already finished[/yellow]")
405
+ elif status_str == "not_found":
406
+ console.print(f"[dim]?[/dim] {job_id_short}...: [dim]not found[/dim]")
407
+ else:
408
+ error = result.get("error", "")
409
+ console.print(f"[red]\u2717[/red] {job_id_short}...{label}: [red]{status_str}[/red] - {error}")
410
+
411
+
293
412
  @app.command("download")
294
413
  def download(
295
414
  job_id: str = typer.Argument(
@@ -312,7 +431,7 @@ def download(
312
431
  amina jobs download abc123 -o ./results/
313
432
  """
314
433
  from amina_cli.auth import get_job_info
315
- from amina_cli.client import check_job_status_sync, ToolExecutionError
434
+ from amina_cli.client import ToolExecutionError
316
435
  from amina_cli.storage import download_results, StorageError
317
436
 
318
437
  # Look up job info
@@ -324,18 +443,16 @@ def download(
324
443
 
325
444
  # Check status
326
445
  try:
327
- status_result = check_job_status_sync(
328
- call_id=job_info.get("call_id", ""),
329
- job_id=job_info.get("job_id", job_id),
330
- user_id=job_info.get("user_id", ""),
331
- tool_name=job_info.get("tool_name", ""),
332
- reserved_cost=job_info.get("reserved_cost", 0.0),
333
- compute_type=job_info.get("compute_type", "cpu"),
334
- )
446
+ status_result = _resolve_job_status(job_info)
335
447
  except ToolExecutionError as e:
336
448
  console.print(f"[red]Error checking job status:[/red] {e}")
337
449
  raise typer.Exit(1)
338
450
 
451
+ if status_result.get("status") == "queued":
452
+ console.print("[yellow]Job is still queued.[/yellow] Wait for completion first:")
453
+ console.print(f" amina jobs wait {job_id}")
454
+ raise typer.Exit(1)
455
+
339
456
  if status_result.get("status") == "running":
340
457
  console.print("[yellow]Job is still running.[/yellow] Wait for completion first:")
341
458
  console.print(f" amina jobs wait {job_id}")
@@ -0,0 +1,121 @@
1
+ """Residue Accessibility analysis tool for the Amina CLI."""
2
+
3
+ import typer
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from rich.console import Console
7
+
8
+ METADATA = {
9
+ "name": "residue_accessibility",
10
+ "display_name": "Residue Accessibility",
11
+ "category": "analysis",
12
+ "description": "Score residues for binder accessibility beyond SASA using depth, visibility, and curvature",
13
+ "modal_function_name": "residue_accessibility_worker",
14
+ "modal_app_name": "residue-accessibility-api",
15
+ "status": "available",
16
+ "outputs": {
17
+ "json_filepath": "Full structured results (residues, patches, recommendation)",
18
+ "csv_filepath": "Per-residue accessibility scores in CSV format",
19
+ "pdb_filepath": "PDB with B-factors set to accessibility tier scores",
20
+ },
21
+ }
22
+
23
+ console = Console()
24
+
25
+
26
+ def register(app: typer.Typer):
27
+ """Register this tool's command with the app."""
28
+ from amina_cli.commands.tools import run_tool_with_progress
29
+
30
+ @app.command("residue-accessibility")
31
+ def run_residue_accessibility(
32
+ pdb: Path = typer.Option(
33
+ ...,
34
+ "--pdb",
35
+ "-p",
36
+ help="Path to PDB file containing protein structure",
37
+ exists=True,
38
+ ),
39
+ output: Optional[Path] = typer.Option(
40
+ None,
41
+ "--output",
42
+ "-o",
43
+ help="Output directory for results (required unless --background)",
44
+ ),
45
+ residues: Optional[str] = typer.Option(
46
+ None,
47
+ "--residues",
48
+ "-r",
49
+ help="Residues to score in CHAIN:RESNUM format (e.g., 'A:42,A:43,B:10'). "
50
+ "If omitted, all surface-exposed residues are scored.",
51
+ ),
52
+ rsa_threshold: float = typer.Option(
53
+ 0.20,
54
+ "--rsa-threshold",
55
+ help="RSA cutoff for the exposure gate (0.0-1.0). Residues below this are excluded.",
56
+ ),
57
+ n_rays: int = typer.Option(
58
+ 200,
59
+ "--n-rays",
60
+ help="Number of hemisphere rays per residue for visibility scoring (10-1000).",
61
+ ),
62
+ job_name: Optional[str] = typer.Option(
63
+ None,
64
+ "--job-name",
65
+ "-j",
66
+ help="Custom job name for output files (default: random 4-letter code)",
67
+ ),
68
+ background: bool = typer.Option(
69
+ False,
70
+ "--background",
71
+ "-b",
72
+ help="Submit job and return immediately without waiting for completion",
73
+ ),
74
+ ):
75
+ """
76
+ Score protein residues for binder accessibility.
77
+
78
+ Goes beyond SASA to measure residue depth, outward visibility (ray casting),
79
+ and surface curvature. Returns a ranked list of residues scored 0–1 for
80
+ binder accessibility.
81
+
82
+ Examples:
83
+ amina run residue-accessibility --pdb ./target.pdb -o ./results/
84
+ amina run residue-accessibility --pdb ./target.pdb -r "A:42,A:43,A:45" -o ./results/
85
+ amina run residue-accessibility --pdb ./target.pdb --rsa-threshold 0.15 -o ./results/
86
+ amina run residue-accessibility --pdb ./target.pdb -j myjob -o ./results/ --background
87
+ """
88
+ # Validate required options
89
+ if output is None and not background:
90
+ console.print("[red]Error:[/red] --output / -o is required (unless using --background)")
91
+ raise typer.Exit(1)
92
+
93
+ # Validate residue format if provided
94
+ if residues:
95
+ for token in residues.split(","):
96
+ token = token.strip()
97
+ if ":" not in token:
98
+ console.print(
99
+ f"[red]Error:[/red] Invalid residue format '{token}'. Expected CHAIN:RESNUM (e.g., 'A:42')."
100
+ )
101
+ raise typer.Exit(1)
102
+
103
+ # Read PDB file content
104
+ pdb_content = pdb.read_text()
105
+ console.print(f"Read PDB file: {pdb.name}")
106
+
107
+ # Build params dict matching worker's expected fields
108
+ params = {
109
+ "pdb_content": pdb_content,
110
+ "rsa_threshold": rsa_threshold,
111
+ "n_rays": n_rays,
112
+ }
113
+
114
+ if residues:
115
+ params["residues"] = residues
116
+
117
+ if job_name:
118
+ params["job_name"] = job_name
119
+
120
+ # Execute
121
+ run_tool_with_progress("residue_accessibility", params, output, background=background)
@@ -80,6 +80,17 @@ def get_queued_status_endpoint() -> str:
80
80
  return f"{base}-queued-job-status.modal.run"
81
81
 
82
82
 
83
+ def get_cancel_endpoint() -> str:
84
+ """
85
+ Get the cancel_job endpoint URL.
86
+
87
+ Returns:
88
+ Cancel job endpoint URL (job_id added as query param by client)
89
+ """
90
+ base = _get_gateway_base()
91
+ return f"{base}-cancel-job.modal.run"
92
+
93
+
83
94
  # Legacy function for backwards compatibility
84
95
  def get_tool_endpoint(name: str) -> str:
85
96
  """
File without changes
File without changes
File without changes