nepher 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.
Files changed (45) hide show
  1. nepher/__init__.py +36 -0
  2. nepher/api/__init__.py +6 -0
  3. nepher/api/client.py +384 -0
  4. nepher/api/endpoints.py +97 -0
  5. nepher/auth.py +150 -0
  6. nepher/cli/__init__.py +2 -0
  7. nepher/cli/commands/__init__.py +6 -0
  8. nepher/cli/commands/auth.py +37 -0
  9. nepher/cli/commands/cache.py +85 -0
  10. nepher/cli/commands/config.py +77 -0
  11. nepher/cli/commands/download.py +72 -0
  12. nepher/cli/commands/list.py +75 -0
  13. nepher/cli/commands/upload.py +69 -0
  14. nepher/cli/commands/view.py +310 -0
  15. nepher/cli/main.py +30 -0
  16. nepher/cli/utils.py +28 -0
  17. nepher/config.py +202 -0
  18. nepher/core.py +67 -0
  19. nepher/env_cfgs/__init__.py +7 -0
  20. nepher/env_cfgs/base.py +32 -0
  21. nepher/env_cfgs/manipulation/__init__.py +4 -0
  22. nepher/env_cfgs/navigation/__init__.py +45 -0
  23. nepher/env_cfgs/navigation/abstract_nav_cfg.py +159 -0
  24. nepher/env_cfgs/navigation/preset_nav_cfg.py +590 -0
  25. nepher/env_cfgs/navigation/usd_nav_cfg.py +644 -0
  26. nepher/env_cfgs/registry.py +31 -0
  27. nepher/loader/__init__.py +9 -0
  28. nepher/loader/base.py +27 -0
  29. nepher/loader/category_loaders/__init__.py +2 -0
  30. nepher/loader/preset_loader.py +80 -0
  31. nepher/loader/registry.py +63 -0
  32. nepher/loader/usd_loader.py +49 -0
  33. nepher/storage/__init__.py +8 -0
  34. nepher/storage/bundle.py +78 -0
  35. nepher/storage/cache.py +145 -0
  36. nepher/storage/manifest.py +80 -0
  37. nepher/utils/__init__.py +12 -0
  38. nepher/utils/fast_spawn_sampler.py +334 -0
  39. nepher/utils/free_zone_finder.py +239 -0
  40. nepher-0.1.0.dist-info/METADATA +235 -0
  41. nepher-0.1.0.dist-info/RECORD +45 -0
  42. nepher-0.1.0.dist-info/WHEEL +5 -0
  43. nepher-0.1.0.dist-info/entry_points.txt +2 -0
  44. nepher-0.1.0.dist-info/licenses/LICENSE +97 -0
  45. nepher-0.1.0.dist-info/top_level.txt +1 -0
nepher/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ Nepher: Universal Isaac Lab Environments Platform
3
+
4
+ A unified, category-agnostic Python package for managing Isaac Lab environments.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from nepher.config import get_config, set_config
10
+ from nepher.auth import login, logout, whoami, get_api_key
11
+ from nepher.api.client import get_client, APIClient
12
+ from nepher.core import Environment, Scene
13
+ from nepher.loader import load_env, load_scene
14
+
15
+ __all__ = [
16
+ # Version
17
+ "__version__",
18
+ # Configuration
19
+ "get_config",
20
+ "set_config",
21
+ # Authentication
22
+ "login",
23
+ "logout",
24
+ "whoami",
25
+ "get_api_key",
26
+ # API Client
27
+ "get_client",
28
+ "APIClient",
29
+ # Core types
30
+ "Environment",
31
+ "Scene",
32
+ # Loaders
33
+ "load_env",
34
+ "load_scene",
35
+ ]
36
+
nepher/api/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """API client for envhub-backend."""
2
+
3
+ from nepher.api.client import APIClient, get_client
4
+
5
+ __all__ = ["APIClient", "get_client"]
6
+
nepher/api/client.py ADDED
@@ -0,0 +1,384 @@
1
+ """
2
+ Category-agnostic API client for envhub-backend.
3
+ """
4
+
5
+ import requests
6
+ from pathlib import Path
7
+ from typing import List, Optional, Dict, Any
8
+ from datetime import datetime
9
+ from nepher.config import get_config
10
+ from nepher.api.endpoints import APIEndpoints
11
+
12
+
13
+ class APIError(Exception):
14
+ """API client error."""
15
+
16
+ pass
17
+
18
+
19
+ class APIClient:
20
+ """Category-agnostic API client."""
21
+
22
+ def __init__(self, api_url: Optional[str] = None, api_key: Optional[str] = None):
23
+ """
24
+ Initialize API client.
25
+
26
+ Args:
27
+ api_url: API base URL (defaults to config)
28
+ api_key: API key or JWT token (defaults to config)
29
+ """
30
+ config = get_config()
31
+ self.api_url = (api_url or config.get_api_url()).rstrip("/")
32
+ self.api_key = api_key or config.get_api_key()
33
+ self.session = requests.Session()
34
+ self._jwt_token: Optional[str] = None
35
+
36
+ if self.api_key:
37
+ if self.api_key.startswith("nepher_"):
38
+ self._raw_api_key = self.api_key
39
+ else:
40
+ self._jwt_token = self.api_key
41
+ self._raw_api_key = None
42
+ self.session.headers.update({"Authorization": f"Bearer {self._jwt_token}"})
43
+ else:
44
+ self._raw_api_key = None
45
+
46
+ def _ensure_jwt_token(self):
47
+ """Exchange API key for JWT token if needed."""
48
+ if self._raw_api_key and not self._jwt_token:
49
+ original_auth = self.session.headers.get("Authorization")
50
+ self.session.headers.pop("Authorization", None)
51
+ try:
52
+ response = self.session.post(
53
+ f"{self.api_url}{APIEndpoints.API_KEY_LOGIN}",
54
+ json={"api_key": self._raw_api_key},
55
+ timeout=30,
56
+ )
57
+ response.raise_for_status()
58
+ login_data = response.json()
59
+ self._jwt_token = login_data.get("access_token")
60
+ if self._jwt_token:
61
+ self.session.headers.update({"Authorization": f"Bearer {self._jwt_token}"})
62
+ except Exception:
63
+ if original_auth:
64
+ self.session.headers.update({"Authorization": original_auth})
65
+ raise
66
+
67
+ def _request(
68
+ self,
69
+ method: str,
70
+ endpoint: str,
71
+ params: Optional[Dict[str, Any]] = None,
72
+ json_data: Optional[Dict[str, Any]] = None,
73
+ data: Optional[Dict[str, Any]] = None,
74
+ files: Optional[Dict[str, Any]] = None,
75
+ stream: bool = False,
76
+ ) -> requests.Response:
77
+ """
78
+ Make HTTP request.
79
+
80
+ Args:
81
+ method: HTTP method (GET, POST, etc.)
82
+ endpoint: API endpoint path
83
+ params: URL query parameters
84
+ json_data: JSON request body (renamed from 'json' to avoid shadowing builtin)
85
+ data: Form data (used when files are present)
86
+ files: Files to upload (multipart/form-data)
87
+ stream: Whether to stream the response
88
+
89
+ Note: When files are provided, data should be used for form fields instead of json_data.
90
+ """
91
+ if endpoint != APIEndpoints.API_KEY_LOGIN:
92
+ self._ensure_jwt_token()
93
+
94
+ url = f"{self.api_url}{endpoint}"
95
+
96
+ try:
97
+ if files:
98
+ response = self.session.request(
99
+ method=method,
100
+ url=url,
101
+ params=params,
102
+ data=data,
103
+ files=files,
104
+ stream=stream,
105
+ timeout=30,
106
+ )
107
+ else:
108
+ response = self.session.request(
109
+ method=method,
110
+ url=url,
111
+ params=params,
112
+ json=json_data,
113
+ data=data,
114
+ stream=stream,
115
+ timeout=30,
116
+ )
117
+ response.raise_for_status()
118
+ return response
119
+ except requests.exceptions.HTTPError as e:
120
+ error_msg = "API request failed"
121
+ if e.response is not None:
122
+ try:
123
+ error_data = e.response.json()
124
+ error_msg = error_data.get("message", error_data.get("detail", error_msg))
125
+ except (ValueError, KeyError):
126
+ error_msg = e.response.text or error_msg
127
+ raise APIError(f"{error_msg}") from e
128
+ except requests.exceptions.RequestException as e:
129
+ raise APIError(f"Request failed: {str(e)}") from e
130
+
131
+ def health_check(self) -> Dict[str, Any]:
132
+ """Check API health."""
133
+ response = self._request("GET", APIEndpoints.HEALTH)
134
+ return response.json()
135
+
136
+ def get_info(self) -> Dict[str, Any]:
137
+ """Get API information."""
138
+ response = self._request("GET", APIEndpoints.INFO)
139
+ return response.json()
140
+
141
+ def list_environments(
142
+ self,
143
+ category: Optional[str] = None,
144
+ type: Optional[str] = None,
145
+ benchmark: Optional[bool] = None,
146
+ search: Optional[str] = None,
147
+ limit: Optional[int] = None,
148
+ offset: Optional[int] = None,
149
+ ) -> List[Dict[str, Any]]:
150
+ """
151
+ List environments.
152
+
153
+ Args:
154
+ category: Filter by category
155
+ type: Filter by type ("usd" or "preset")
156
+ benchmark: Filter by benchmark status
157
+ search: Search query
158
+ limit: Maximum number of results
159
+ offset: Offset for pagination
160
+
161
+ Returns:
162
+ List of environment dictionaries
163
+ """
164
+ params = {}
165
+ if category:
166
+ params["category"] = category
167
+ if type:
168
+ params["type"] = type
169
+ if benchmark is not None:
170
+ params["benchmark"] = benchmark
171
+ if search:
172
+ params["search"] = search
173
+ if limit:
174
+ params["limit"] = limit
175
+ if offset:
176
+ params["offset"] = offset
177
+
178
+ response = self._request("GET", APIEndpoints.ENVS, params=params)
179
+ result = response.json()
180
+ if isinstance(result, dict) and "environments" in result:
181
+ return result["environments"]
182
+ return result if isinstance(result, list) else []
183
+
184
+ def list_eval_benchmarks(self) -> List[Dict[str, Any]]:
185
+ """
186
+ List evaluation benchmarks.
187
+
188
+ Returns:
189
+ List of evaluation benchmark dictionaries
190
+ """
191
+ response = self._request("GET", APIEndpoints.ENVS_EVAL_BENCHMARKS)
192
+ result = response.json()
193
+ if isinstance(result, dict) and "environments" in result:
194
+ return result["environments"]
195
+ return result if isinstance(result, list) else []
196
+
197
+ def get_environment(self, env_id: str) -> Dict[str, Any]:
198
+ """Get environment details."""
199
+ response = self._request("GET", APIEndpoints.env(env_id))
200
+ return response.json()
201
+
202
+ def download_environment(self, env_id: str, dest_path: Path) -> Path:
203
+ """
204
+ Download environment bundle.
205
+
206
+ Args:
207
+ env_id: Environment ID
208
+ dest_path: Destination path for the ZIP file
209
+
210
+ Returns:
211
+ Path to downloaded file
212
+
213
+ Raises:
214
+ APIError: If download fails
215
+ OSError: If file cannot be written
216
+ """
217
+ response = self._request("GET", APIEndpoints.env_download(env_id), stream=True)
218
+
219
+ try:
220
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
221
+
222
+ with open(dest_path, "wb") as f:
223
+ for chunk in response.iter_content(chunk_size=8192):
224
+ if chunk: # Filter out keep-alive chunks
225
+ f.write(chunk)
226
+ except (OSError, IOError) as e:
227
+ raise OSError(f"Cannot write downloaded file to {dest_path}: {e}") from e
228
+
229
+ return dest_path
230
+
231
+ def upload_environment(
232
+ self,
233
+ bundle_path: Path,
234
+ category: str,
235
+ benchmark: bool = False,
236
+ force: bool = False,
237
+ duplicate_policy: str = "reject",
238
+ thumbnail: Optional[Path] = None,
239
+ ) -> Dict[str, Any]:
240
+ """
241
+ Upload environment bundle.
242
+
243
+ Args:
244
+ bundle_path: Path to bundle ZIP file
245
+ category: Environment category
246
+ benchmark: Whether this is a benchmark environment
247
+ force: Force upload even if duplicate exists
248
+ duplicate_policy: Policy for duplicates ("reject", "allow", "update")
249
+ thumbnail: Optional thumbnail image path
250
+
251
+ Returns:
252
+ Upload result dictionary
253
+ """
254
+ bundle_file = open(bundle_path, "rb")
255
+ files = {"bundle": (bundle_path.name, bundle_file, "application/zip")}
256
+ file_handles = [bundle_file]
257
+
258
+ if thumbnail:
259
+ thumbnail_path = Path(thumbnail)
260
+ content_type = "image/jpeg"
261
+ if thumbnail_path.suffix.lower() == ".png":
262
+ content_type = "image/png"
263
+ elif thumbnail_path.suffix.lower() == ".webp":
264
+ content_type = "image/webp"
265
+ elif thumbnail_path.suffix.lower() in [".jpg", ".jpeg"]:
266
+ content_type = "image/jpeg"
267
+
268
+ thumbnail_file = open(thumbnail, "rb")
269
+ files["thumbnail"] = (thumbnail_path.name, thumbnail_file, content_type)
270
+ file_handles.append(thumbnail_file)
271
+
272
+ data = {
273
+ "category": category,
274
+ "benchmark": str(benchmark).lower(),
275
+ "force": str(force).lower(),
276
+ "duplicate_policy": duplicate_policy,
277
+ }
278
+
279
+ try:
280
+ response = self._request("POST", APIEndpoints.ENVS, files=files, data=data)
281
+ return response.json()
282
+ finally:
283
+ for f in file_handles:
284
+ f.close()
285
+
286
+ def get_user_info(self) -> Dict[str, Any]:
287
+ """Get current user information."""
288
+ response = self._request("GET", APIEndpoints.USERS_ME)
289
+ return response.json()
290
+
291
+ def api_key_login(self, api_key: str) -> Dict[str, Any]:
292
+ """
293
+ Exchange API key for JWT tokens.
294
+
295
+ Args:
296
+ api_key: API key to exchange
297
+
298
+ Returns:
299
+ Dictionary containing access_token, refresh_token, and user info
300
+ """
301
+ response = self._request(
302
+ "POST", APIEndpoints.API_KEY_LOGIN, json_data={"api_key": api_key}
303
+ )
304
+ return response.json()
305
+
306
+ def create_api_key(
307
+ self,
308
+ name: Optional[str] = None,
309
+ expires_at: Optional[datetime] = None,
310
+ ) -> Dict[str, Any]:
311
+ """
312
+ Create a new API key.
313
+
314
+ Args:
315
+ name: Optional name for the API key
316
+ expires_at: Optional expiration date
317
+
318
+ Returns:
319
+ Dictionary containing API key (shown only once) and metadata
320
+ """
321
+ data = {}
322
+ if name:
323
+ data["name"] = name
324
+ if expires_at:
325
+ data["expires_at"] = expires_at.isoformat()
326
+ response = self._request("POST", APIEndpoints.API_KEYS, json_data=data)
327
+ return response.json()
328
+
329
+ def list_api_keys(self) -> List[Dict[str, Any]]:
330
+ """
331
+ List user's API keys.
332
+
333
+ Returns:
334
+ List of API key dictionaries (without key values)
335
+ """
336
+ response = self._request("GET", APIEndpoints.API_KEYS)
337
+ return response.json()
338
+
339
+ def get_api_key(self, api_key_id: str) -> Dict[str, Any]:
340
+ """
341
+ Get API key details.
342
+
343
+ Args:
344
+ api_key_id: API key ID
345
+
346
+ Returns:
347
+ API key dictionary (without key value)
348
+ """
349
+ response = self._request("GET", APIEndpoints.api_key(api_key_id))
350
+ return response.json()
351
+
352
+ def delete_api_key(self, api_key_id: str) -> None:
353
+ """
354
+ Delete an API key.
355
+
356
+ Args:
357
+ api_key_id: API key ID
358
+ """
359
+ self._request("DELETE", APIEndpoints.api_key(api_key_id))
360
+
361
+ def regenerate_api_key(self, api_key_id: str) -> Dict[str, Any]:
362
+ """
363
+ Regenerate an API key (creates new key, deactivates old one).
364
+
365
+ Args:
366
+ api_key_id: API key ID to regenerate
367
+
368
+ Returns:
369
+ Dictionary containing new API key (shown only once) and metadata
370
+ """
371
+ response = self._request("POST", APIEndpoints.api_key_regenerate(api_key_id))
372
+ return response.json()
373
+
374
+
375
+ _client_instance: Optional[APIClient] = None
376
+
377
+
378
+ def get_client(api_url: Optional[str] = None, api_key: Optional[str] = None) -> APIClient:
379
+ """Get global API client instance."""
380
+ global _client_instance
381
+ if _client_instance is None:
382
+ _client_instance = APIClient(api_url=api_url, api_key=api_key)
383
+ return _client_instance
384
+
@@ -0,0 +1,97 @@
1
+ """
2
+ API endpoint definitions for envhub-backend.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class APIEndpoints:
9
+ """API endpoint paths."""
10
+
11
+ # Health & Info
12
+ HEALTH = "/api/v1/health"
13
+ INFO = "/" # Root endpoint for API info
14
+
15
+ # Environment Management
16
+ ENVS = "/api/v1/envs/"
17
+ ENVS_PUBLIC = "/api/v1/envs/public/"
18
+ ENVS_BENCHMARK = "/api/v1/envs/benchmark/"
19
+ ENVS_EVAL_BENCHMARKS = "/api/v1/envs/eval-benchmarks/"
20
+ ENVS_PENDING = "/api/v1/envs/pending/"
21
+ ENVS_TRASH = "/api/v1/envs/trash/"
22
+
23
+ @staticmethod
24
+ def env(env_id: str) -> str:
25
+ """Get environment endpoint."""
26
+ return f"/api/v1/envs/{env_id}"
27
+
28
+ @staticmethod
29
+ def env_download(env_id: str) -> str:
30
+ """Download environment endpoint."""
31
+ return f"/api/v1/envs/{env_id}/download"
32
+
33
+ @staticmethod
34
+ def env_thumbnail(env_id: str) -> str:
35
+ """Get environment thumbnail endpoint."""
36
+ return f"/api/v1/envs/{env_id}/thumbnail"
37
+
38
+ @staticmethod
39
+ def env_approve(env_id: str) -> str:
40
+ """Approve environment endpoint."""
41
+ return f"/api/v1/envs/{env_id}/approve"
42
+
43
+ @staticmethod
44
+ def env_reject(env_id: str) -> str:
45
+ """Reject environment endpoint."""
46
+ return f"/api/v1/envs/{env_id}/reject"
47
+
48
+ @staticmethod
49
+ def env_activate_evaluation(env_id: str) -> str:
50
+ """Activate evaluation endpoint."""
51
+ return f"/api/v1/envs/{env_id}/activate-evaluation"
52
+
53
+ @staticmethod
54
+ def env_deactivate_evaluation(env_id: str) -> str:
55
+ """Deactivate evaluation endpoint."""
56
+ return f"/api/v1/envs/{env_id}/deactivate-evaluation"
57
+
58
+ @staticmethod
59
+ def env_toggle_benchmark(env_id: str) -> str:
60
+ """Toggle benchmark endpoint."""
61
+ return f"/api/v1/envs/{env_id}/toggle-benchmark"
62
+
63
+ @staticmethod
64
+ def env_restore(env_id: str) -> str:
65
+ """Restore environment endpoint."""
66
+ return f"/api/v1/envs/{env_id}/restore"
67
+
68
+ # Authentication
69
+ API_KEY_LOGIN = "/api/v1/auth/api-key/login"
70
+
71
+ # API Key Management
72
+ API_KEYS = "/api/v1/api-keys/"
73
+
74
+ @staticmethod
75
+ def api_key(api_key_id: str) -> str:
76
+ """Get/delete API key endpoint."""
77
+ return f"/api/v1/api-keys/{api_key_id}"
78
+
79
+ @staticmethod
80
+ def api_key_regenerate(api_key_id: str) -> str:
81
+ """Regenerate API key endpoint."""
82
+ return f"/api/v1/api-keys/{api_key_id}/regenerate"
83
+
84
+ # User Management
85
+ USERS_ME = "/api/v1/users/me"
86
+ USERS = "/api/v1/users/"
87
+
88
+ @staticmethod
89
+ def user_envhub_role(user_id: str) -> str:
90
+ """Get/update user envhub role endpoint."""
91
+ return f"/api/v1/users/{user_id}/envhub-role"
92
+
93
+ @staticmethod
94
+ def user_status(user_id: str) -> str:
95
+ """Update user status endpoint."""
96
+ return f"/api/v1/users/{user_id}/status"
97
+
nepher/auth.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ Authentication management for Nepher.
3
+
4
+ Handles secure API key storage and user authentication.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Optional
10
+ from nepher.config import get_config, set_config
11
+ from nepher.api.client import APIClient, APIError
12
+
13
+ try:
14
+ import keyring
15
+ HAS_KEYRING = True
16
+ except ImportError:
17
+ HAS_KEYRING = False
18
+
19
+
20
+ def _get_keyring_service() -> str:
21
+ """Get keyring service name."""
22
+ return "nepher"
23
+
24
+
25
+ def _get_keyring_username() -> str:
26
+ """Get keyring username."""
27
+ return "api_key"
28
+
29
+
30
+ def _get_encrypted_file_path() -> Path:
31
+ """Get path to encrypted API key file."""
32
+ config_dir = Path.home() / ".nepher"
33
+ config_dir.mkdir(parents=True, exist_ok=True)
34
+ return config_dir / "api_key.enc"
35
+
36
+
37
+ def _store_api_key_secure(api_key: str):
38
+ """Store API key securely (keyring or encrypted file)."""
39
+ if HAS_KEYRING:
40
+ try:
41
+ keyring.set_password(_get_keyring_service(), _get_keyring_username(), api_key)
42
+ return
43
+ except Exception:
44
+ pass # Fall back to file storage
45
+
46
+ import base64
47
+
48
+ encrypted = base64.b64encode(api_key.encode()).decode()
49
+ _get_encrypted_file_path().write_text(encrypted)
50
+
51
+
52
+ def _get_api_key_secure() -> Optional[str]:
53
+ """Retrieve API key from secure storage."""
54
+ if HAS_KEYRING:
55
+ try:
56
+ key = keyring.get_password(_get_keyring_service(), _get_keyring_username())
57
+ if key:
58
+ return key
59
+ except Exception:
60
+ pass
61
+
62
+ key_file = _get_encrypted_file_path()
63
+ if key_file.exists():
64
+ try:
65
+ import base64
66
+
67
+ encrypted = key_file.read_text()
68
+ return base64.b64decode(encrypted.encode()).decode()
69
+ except Exception:
70
+ pass
71
+
72
+ return None
73
+
74
+
75
+ def _clear_api_key_secure():
76
+ """Clear API key from secure storage."""
77
+ if HAS_KEYRING:
78
+ try:
79
+ keyring.delete_password(_get_keyring_service(), _get_keyring_username())
80
+ except Exception:
81
+ pass
82
+
83
+ key_file = _get_encrypted_file_path()
84
+ if key_file.exists():
85
+ key_file.unlink()
86
+
87
+
88
+ def login(api_key: str) -> bool:
89
+ """
90
+ Login with API key.
91
+
92
+ Args:
93
+ api_key: API key to store
94
+
95
+ Returns:
96
+ True if login successful, False otherwise
97
+ """
98
+ try:
99
+ client = APIClient(api_key=None)
100
+ response = client.api_key_login(api_key)
101
+ if not response or "access_token" not in response:
102
+ return False
103
+ except APIError:
104
+ return False
105
+
106
+ # Store API key securely
107
+ _store_api_key_secure(api_key)
108
+ set_config("api_key", api_key, save=True)
109
+
110
+ return True
111
+
112
+
113
+ def logout():
114
+ """Logout and clear stored credentials."""
115
+ _clear_api_key_secure()
116
+ set_config("api_key", None, save=True)
117
+
118
+
119
+ def whoami() -> Optional[dict]:
120
+ """
121
+ Get current user information.
122
+
123
+ Returns:
124
+ User info dictionary or None if not authenticated
125
+ """
126
+ api_key = get_api_key()
127
+ if not api_key:
128
+ return None
129
+
130
+ try:
131
+ client = APIClient(api_key=api_key)
132
+ return client.get_user_info()
133
+ except APIError:
134
+ return None
135
+
136
+
137
+ def get_api_key() -> Optional[str]:
138
+ """
139
+ Get stored API key.
140
+
141
+ Returns:
142
+ API key or None if not set
143
+ """
144
+ key = _get_api_key_secure()
145
+ if key:
146
+ return key
147
+
148
+ config = get_config()
149
+ return config.get_api_key() if config else None
150
+
nepher/cli/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """CLI for Nepher."""
2
+
@@ -0,0 +1,6 @@
1
+ """CLI commands."""
2
+
3
+ from nepher.cli.commands import auth, download, upload, cache, view, config
4
+ from nepher.cli.commands import list as list_cmd
5
+
6
+ __all__ = ["auth", "list_cmd", "download", "upload", "cache", "view", "config"]