amina-cli 0.1.0__py3-none-any.whl
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.
- amina_cli/__init__.py +12 -0
- amina_cli/auth.py +338 -0
- amina_cli/client.py +847 -0
- amina_cli/commands/__init__.py +9 -0
- amina_cli/commands/auth_cmd.py +181 -0
- amina_cli/commands/jobs_cmd.py +351 -0
- amina_cli/commands/run_cmd.py +46 -0
- amina_cli/commands/tools/__init__.py +382 -0
- amina_cli/commands/tools/analysis/__init__.py +1 -0
- amina_cli/commands/tools/analysis/hydrophobicity.py +105 -0
- amina_cli/commands/tools/analysis/mmseqs2_cluster.py +149 -0
- amina_cli/commands/tools/analysis/rmsd.py +188 -0
- amina_cli/commands/tools/analysis/sasa.py +86 -0
- amina_cli/commands/tools/analysis/simple_rmsd.py +131 -0
- amina_cli/commands/tools/analysis/surface_charge.py +171 -0
- amina_cli/commands/tools/analysis/usalign.py +158 -0
- amina_cli/commands/tools/design/__init__.py +1 -0
- amina_cli/commands/tools/design/esm_if1.py +166 -0
- amina_cli/commands/tools/design/protein_mc.py +145 -0
- amina_cli/commands/tools/design/proteinmpnn.py +137 -0
- amina_cli/commands/tools/design/rfdiffusion.py +306 -0
- amina_cli/commands/tools/display.py +257 -0
- amina_cli/commands/tools/folding/__init__.py +1 -0
- amina_cli/commands/tools/folding/boltz2.py +382 -0
- amina_cli/commands/tools/folding/esmfold.py +105 -0
- amina_cli/commands/tools/folding/openfold3.py +400 -0
- amina_cli/commands/tools/folding/protenix.py +421 -0
- amina_cli/commands/tools/interactions/__init__.py +1 -0
- amina_cli/commands/tools/interactions/autodock_vina.py +194 -0
- amina_cli/commands/tools/interactions/diffdock.py +166 -0
- amina_cli/commands/tools/interactions/dockq.py +174 -0
- amina_cli/commands/tools/interactions/emngly.py +86 -0
- amina_cli/commands/tools/interactions/glycosylation_ensemble.py +146 -0
- amina_cli/commands/tools/interactions/interface_identifier.py +99 -0
- amina_cli/commands/tools/interactions/isoglyp.py +132 -0
- amina_cli/commands/tools/interactions/lmngly.py +108 -0
- amina_cli/commands/tools/interactions/p2rank.py +81 -0
- amina_cli/commands/tools/interactions/pesto.py +137 -0
- amina_cli/commands/tools/properties/__init__.py +1 -0
- amina_cli/commands/tools/properties/aminosol.py +152 -0
- amina_cli/commands/tools/properties/esm2_embedding.py +157 -0
- amina_cli/commands/tools/utilities/__init__.py +1 -0
- amina_cli/commands/tools/utilities/activesite_verifier.py +127 -0
- amina_cli/commands/tools/utilities/chain_select.py +167 -0
- amina_cli/commands/tools/utilities/distance_calculator.py +183 -0
- amina_cli/commands/tools/utilities/maxit_convert.py +101 -0
- amina_cli/commands/tools/utilities/mol_size_calculator.py +77 -0
- amina_cli/commands/tools/utilities/obabel_convert.py +123 -0
- amina_cli/commands/tools/utilities/pdb_bfactor_overwrite.py +130 -0
- amina_cli/commands/tools/utilities/pdb_cleaner.py +96 -0
- amina_cli/commands/tools/utilities/pdb_quality_assessment.py +79 -0
- amina_cli/commands/tools/utilities/pdb_to_fasta.py +76 -0
- amina_cli/commands/tools/utilities/protein_relaxer.py +123 -0
- amina_cli/commands/tools_cmd.py +284 -0
- amina_cli/main.py +72 -0
- amina_cli/registry.py +184 -0
- amina_cli/storage.py +295 -0
- amina_cli-0.1.0.dist-info/METADATA +136 -0
- amina_cli-0.1.0.dist-info/RECORD +62 -0
- amina_cli-0.1.0.dist-info/WHEEL +4 -0
- amina_cli-0.1.0.dist-info/entry_points.txt +2 -0
- amina_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
amina_cli/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Amina CLI - Command-line interface for AminoAnalytica protein engineering platform.
|
|
3
|
+
|
|
4
|
+
Installation:
|
|
5
|
+
pip install amina-cli
|
|
6
|
+
|
|
7
|
+
Quick start:
|
|
8
|
+
amina auth set-key "ami_your_api_key"
|
|
9
|
+
amina run esmfold --sequence "MKFLILLFNILCLFPVLAADNH"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
amina_cli/auth.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local credential storage for Amina CLI.
|
|
3
|
+
|
|
4
|
+
This module handles storing and retrieving API keys locally.
|
|
5
|
+
Validation happens server-side when the key is used.
|
|
6
|
+
|
|
7
|
+
Credentials are stored in ~/.amina/credentials.json
|
|
8
|
+
|
|
9
|
+
This is the same pattern used by:
|
|
10
|
+
- OpenAI CLI: stores key in ~/.openai
|
|
11
|
+
- AWS CLI: stores in ~/.aws/credentials
|
|
12
|
+
- Stripe CLI: stores in ~/.config/stripe
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
# Configuration directory and files
|
|
20
|
+
CONFIG_DIR = Path.home() / ".amina"
|
|
21
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
22
|
+
|
|
23
|
+
# API key format validation
|
|
24
|
+
API_KEY_PREFIX = "ami_"
|
|
25
|
+
API_KEY_MIN_LENGTH = 36 # ami_ + 32 hex chars
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthError(Exception):
|
|
29
|
+
"""Raised when authentication fails or credentials are missing."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_key_format(key: str) -> bool:
|
|
35
|
+
"""
|
|
36
|
+
Validate that a key has the correct format.
|
|
37
|
+
|
|
38
|
+
Only checks format, NOT validity. Server validates when key is used.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
key: The API key to validate
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if format is valid
|
|
45
|
+
"""
|
|
46
|
+
if not key:
|
|
47
|
+
return False
|
|
48
|
+
if not key.startswith(API_KEY_PREFIX):
|
|
49
|
+
return False
|
|
50
|
+
if len(key) < API_KEY_MIN_LENGTH:
|
|
51
|
+
return False
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_api_key(key: str) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Store an API key locally.
|
|
58
|
+
|
|
59
|
+
Format is validated but the key is NOT verified against the server.
|
|
60
|
+
Verification happens when the key is used for the first time.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
key: The API key to store
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If key format is invalid
|
|
67
|
+
"""
|
|
68
|
+
if not validate_key_format(key):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Invalid API key format. Must start with '{API_KEY_PREFIX}' "
|
|
71
|
+
f"and be at least {API_KEY_MIN_LENGTH} characters."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
save_credentials({"api_key": key})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_credentials(data: dict) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Save credentials to the config file.
|
|
80
|
+
|
|
81
|
+
Follows the same security pattern as OpenAI CLI:
|
|
82
|
+
- Directory: 0o700 (owner only)
|
|
83
|
+
- File: 0o600 (owner read/write only)
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
data: Dictionary of credentials to save
|
|
87
|
+
"""
|
|
88
|
+
import os
|
|
89
|
+
|
|
90
|
+
# Create config directory with restrictive permissions (owner only)
|
|
91
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
92
|
+
|
|
93
|
+
# Write credentials file atomically with secure permissions
|
|
94
|
+
# This prevents a race condition where the file briefly exists with wrong permissions
|
|
95
|
+
content = json.dumps(data, indent=2)
|
|
96
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
97
|
+
with os.fdopen(os.open(CREDENTIALS_FILE, flags, 0o600), "w") as f:
|
|
98
|
+
f.write(content)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_credentials() -> dict:
|
|
102
|
+
"""
|
|
103
|
+
Load stored credentials.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Dictionary with 'api_key' and any other stored data
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
AuthError: If not authenticated
|
|
110
|
+
"""
|
|
111
|
+
if not CREDENTIALS_FILE.exists():
|
|
112
|
+
raise AuthError(
|
|
113
|
+
"Not authenticated. Run: amina auth set-key <your_api_key>\n"
|
|
114
|
+
"Get an API key at: https://app.aminoanalytica.com/settings/api"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
data = json.loads(CREDENTIALS_FILE.read_text())
|
|
119
|
+
if not data.get("api_key"):
|
|
120
|
+
raise AuthError("Credentials file exists but no API key found.")
|
|
121
|
+
return data
|
|
122
|
+
except json.JSONDecodeError:
|
|
123
|
+
raise AuthError("Credentials file is corrupted. Run: amina auth set-key <key>")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_api_key() -> str:
|
|
127
|
+
"""
|
|
128
|
+
Get the stored API key.
|
|
129
|
+
|
|
130
|
+
Convenience wrapper around get_credentials().
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The stored API key
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
AuthError: If not authenticated
|
|
137
|
+
"""
|
|
138
|
+
return get_credentials()["api_key"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def clear_credentials() -> None:
|
|
142
|
+
"""
|
|
143
|
+
Remove all stored credentials (logout).
|
|
144
|
+
|
|
145
|
+
Idempotent - safe to call even if not authenticated.
|
|
146
|
+
"""
|
|
147
|
+
if CREDENTIALS_FILE.exists():
|
|
148
|
+
CREDENTIALS_FILE.unlink()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_key_display(key: str) -> str:
|
|
152
|
+
"""
|
|
153
|
+
Get a safe display version of an API key.
|
|
154
|
+
|
|
155
|
+
Shows prefix and first few chars, hides the rest.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
key: The full API key
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Safe display string like "ami_7f3k..."
|
|
162
|
+
"""
|
|
163
|
+
if len(key) > 12:
|
|
164
|
+
return f"{key[:12]}..."
|
|
165
|
+
return key
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_authenticated() -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Check if credentials are stored.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
True if credentials file exists with an API key
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
get_credentials()
|
|
177
|
+
return True
|
|
178
|
+
except AuthError:
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_session_id() -> Optional[str]:
|
|
183
|
+
"""
|
|
184
|
+
Get the linked session/conversation ID if set.
|
|
185
|
+
|
|
186
|
+
When a session ID is set, CLI outputs will be linked to that
|
|
187
|
+
web conversation for visibility in the dashboard.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Session ID or None if not linked
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
data = get_credentials()
|
|
194
|
+
return data.get("session_id")
|
|
195
|
+
except AuthError:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def set_session_id(session_id: Optional[str]) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Link CLI outputs to a web conversation.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
session_id: The conversation ID to link, or None to unlink
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
data = get_credentials()
|
|
208
|
+
except AuthError:
|
|
209
|
+
raise AuthError("Must be authenticated before linking a session.")
|
|
210
|
+
|
|
211
|
+
if session_id:
|
|
212
|
+
data["session_id"] = session_id
|
|
213
|
+
else:
|
|
214
|
+
data.pop("session_id", None)
|
|
215
|
+
|
|
216
|
+
save_credentials(data)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
220
|
+
# JOB HISTORY STORAGE
|
|
221
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
222
|
+
|
|
223
|
+
JOBS_FILE = CONFIG_DIR / "jobs.json"
|
|
224
|
+
MAX_JOB_HISTORY = 100 # Keep last N jobs
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def save_job(job_info: dict) -> None:
|
|
228
|
+
"""
|
|
229
|
+
Save a job to the local history.
|
|
230
|
+
|
|
231
|
+
Used by background job submission to track jobs for later status checks.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
job_info: Dict with job_id, call_id, user_id, tool_name, etc.
|
|
235
|
+
"""
|
|
236
|
+
from datetime import datetime
|
|
237
|
+
import os
|
|
238
|
+
|
|
239
|
+
# Load existing jobs
|
|
240
|
+
jobs = []
|
|
241
|
+
if JOBS_FILE.exists():
|
|
242
|
+
try:
|
|
243
|
+
jobs = json.loads(JOBS_FILE.read_text())
|
|
244
|
+
except (json.JSONDecodeError, IOError):
|
|
245
|
+
jobs = []
|
|
246
|
+
|
|
247
|
+
# Add timestamp and status
|
|
248
|
+
job_info["submitted_at"] = datetime.now().isoformat()
|
|
249
|
+
job_info["status"] = "submitted"
|
|
250
|
+
|
|
251
|
+
# Add to front of list (most recent first)
|
|
252
|
+
jobs.insert(0, job_info)
|
|
253
|
+
|
|
254
|
+
# Trim to max history
|
|
255
|
+
jobs = jobs[:MAX_JOB_HISTORY]
|
|
256
|
+
|
|
257
|
+
# Ensure config directory exists
|
|
258
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
259
|
+
|
|
260
|
+
# Write atomically with secure permissions
|
|
261
|
+
content = json.dumps(jobs, indent=2)
|
|
262
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
263
|
+
with os.fdopen(os.open(JOBS_FILE, flags, 0o600), "w") as f:
|
|
264
|
+
f.write(content)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_job_history(limit: int = 20) -> list[dict]:
|
|
268
|
+
"""
|
|
269
|
+
Get recent jobs from local history.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
limit: Maximum number of jobs to return
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of job info dicts, most recent first
|
|
276
|
+
"""
|
|
277
|
+
if not JOBS_FILE.exists():
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
jobs = json.loads(JOBS_FILE.read_text())
|
|
282
|
+
return jobs[:limit]
|
|
283
|
+
except (json.JSONDecodeError, IOError):
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_job_info(job_id: str) -> Optional[dict]:
|
|
288
|
+
"""
|
|
289
|
+
Look up a job by ID (full or partial match).
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
job_id: Full job ID or prefix to match
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Job info dict or None if not found
|
|
296
|
+
"""
|
|
297
|
+
jobs = get_job_history(limit=MAX_JOB_HISTORY)
|
|
298
|
+
|
|
299
|
+
# Try exact match first
|
|
300
|
+
for job in jobs:
|
|
301
|
+
if job.get("job_id") == job_id:
|
|
302
|
+
return job
|
|
303
|
+
|
|
304
|
+
# Try prefix match
|
|
305
|
+
for job in jobs:
|
|
306
|
+
if job.get("job_id", "").startswith(job_id):
|
|
307
|
+
return job
|
|
308
|
+
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def update_job_status(job_id: str, status: str) -> None:
|
|
313
|
+
"""
|
|
314
|
+
Update the status of a job in local history.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
job_id: Job ID to update
|
|
318
|
+
status: New status (e.g., "completed", "failed")
|
|
319
|
+
"""
|
|
320
|
+
import os
|
|
321
|
+
|
|
322
|
+
if not JOBS_FILE.exists():
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
jobs = json.loads(JOBS_FILE.read_text())
|
|
327
|
+
except (json.JSONDecodeError, IOError):
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
for job in jobs:
|
|
331
|
+
if job.get("job_id") == job_id:
|
|
332
|
+
job["status"] = status
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
content = json.dumps(jobs, indent=2)
|
|
336
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
337
|
+
with os.fdopen(os.open(JOBS_FILE, flags, 0o600), "w") as f:
|
|
338
|
+
f.write(content)
|