nepher-cli 0.2.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.
@@ -0,0 +1,243 @@
1
+ """Persistent credential storage for npcli.
2
+
3
+ Stores API key, JWT access/refresh tokens, and user metadata in
4
+ ~/.nepher/credentials.json (or %APPDATA%\\nepher\\credentials.json on Windows).
5
+
6
+ Sensitive values (tokens) are also mirrored into the system keyring when
7
+ the ``keyring`` package is available, and the JSON file contains only
8
+ non-secret metadata plus the expiry timestamp in that case.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ try:
21
+ import keyring as _keyring
22
+
23
+ _HAS_KEYRING = True
24
+ except ImportError: # pragma: no cover
25
+ _HAS_KEYRING = False
26
+
27
+ try:
28
+ from platformdirs import user_config_dir
29
+
30
+ def _config_dir() -> Path:
31
+ return Path(user_config_dir("nepher", appauthor=False))
32
+
33
+ except ImportError: # pragma: no cover
34
+ def _config_dir() -> Path: # type: ignore[misc]
35
+ return Path.home() / ".nepher"
36
+
37
+
38
+ _KEYRING_SERVICE = "npcli"
39
+ _KEYRING_ACCESS = "access_token"
40
+ _KEYRING_REFRESH = "refresh_token"
41
+ _KEYRING_API_KEY = "api_key"
42
+
43
+
44
+ def _cred_path() -> Path:
45
+ d = _config_dir()
46
+ d.mkdir(parents=True, exist_ok=True)
47
+ return d / "credentials.json"
48
+
49
+
50
+ def _read_json() -> dict[str, Any]:
51
+ p = _cred_path()
52
+ if not p.exists():
53
+ return {}
54
+ try:
55
+ return json.loads(p.read_text(encoding="utf-8"))
56
+ except (json.JSONDecodeError, OSError):
57
+ return {}
58
+
59
+
60
+ def _write_json(data: dict[str, Any]) -> None:
61
+ p = _cred_path()
62
+ p.parent.mkdir(parents=True, exist_ok=True)
63
+ p.write_text(json.dumps(data, indent=2), encoding="utf-8")
64
+ try:
65
+ p.chmod(0o600)
66
+ except OSError:
67
+ pass
68
+
69
+
70
+ def _kr_get(key: str) -> str | None:
71
+ if not _HAS_KEYRING:
72
+ return None
73
+ try:
74
+ return _keyring.get_password(_KEYRING_SERVICE, key)
75
+ except Exception:
76
+ return None
77
+
78
+
79
+ def _kr_set(key: str, value: str) -> bool:
80
+ if not _HAS_KEYRING:
81
+ return False
82
+ try:
83
+ _keyring.set_password(_KEYRING_SERVICE, key, value)
84
+ return True
85
+ except Exception:
86
+ return False
87
+
88
+
89
+ def _kr_delete(key: str) -> None:
90
+ if not _HAS_KEYRING:
91
+ return
92
+ try:
93
+ _keyring.delete_password(_KEYRING_SERVICE, key)
94
+ except Exception:
95
+ pass
96
+
97
+
98
+ def save_credentials(
99
+ *,
100
+ api_key: str,
101
+ access_token: str,
102
+ refresh_token: str,
103
+ expires_in: int,
104
+ user: dict[str, Any],
105
+ ) -> None:
106
+ """Persist all credential material after a successful cli-login."""
107
+ expires_at = int(time.time()) + expires_in
108
+
109
+ stored_in_keyring = all([
110
+ _kr_set(_KEYRING_API_KEY, api_key),
111
+ _kr_set(_KEYRING_ACCESS, access_token),
112
+ _kr_set(_KEYRING_REFRESH, refresh_token),
113
+ ])
114
+
115
+ meta: dict[str, Any] = {
116
+ "expires_at": expires_at,
117
+ "user": user,
118
+ "keyring": stored_in_keyring,
119
+ }
120
+
121
+ if not stored_in_keyring:
122
+ meta["api_key"] = api_key
123
+ meta["access_token"] = access_token
124
+ meta["refresh_token"] = refresh_token
125
+
126
+ _write_json(meta)
127
+
128
+
129
+ def load_credentials() -> dict[str, Any] | None:
130
+ """Return the stored credential dict or None if nothing is saved."""
131
+ meta = _read_json()
132
+ if not meta:
133
+ return None
134
+
135
+ if meta.get("keyring"):
136
+ meta["api_key"] = _kr_get(_KEYRING_API_KEY)
137
+ meta["access_token"] = _kr_get(_KEYRING_ACCESS)
138
+ meta["refresh_token"] = _kr_get(_KEYRING_REFRESH)
139
+
140
+ if not meta.get("access_token") and not meta.get("api_key"):
141
+ return None
142
+
143
+ return meta
144
+
145
+
146
+ def clear_credentials() -> None:
147
+ """Delete all stored credential material."""
148
+ _kr_delete(_KEYRING_API_KEY)
149
+ _kr_delete(_KEYRING_ACCESS)
150
+ _kr_delete(_KEYRING_REFRESH)
151
+ p = _cred_path()
152
+ if p.exists():
153
+ p.unlink()
154
+
155
+
156
+ def _is_expired(creds: dict[str, Any]) -> bool:
157
+ expires_at = creds.get("expires_at")
158
+ if not isinstance(expires_at, (int, float)):
159
+ return True
160
+ return time.time() > (expires_at - 60)
161
+
162
+
163
+ def _refresh_access_token(refresh_token: str, account_base: str) -> dict[str, Any] | None:
164
+ """Call /api/v1/auth/refresh and return updated token data, or None on failure."""
165
+ url = f"{account_base.rstrip('/')}/api/v1/auth/refresh"
166
+ try:
167
+ r = httpx.post(url, json={"refresh_token": refresh_token}, timeout=30.0)
168
+ except httpx.RequestError:
169
+ return None
170
+ if r.status_code != 200:
171
+ return None
172
+ try:
173
+ return r.json()
174
+ except Exception:
175
+ return None
176
+
177
+
178
+ def get_auth_headers(
179
+ api_key_override: str | None = None,
180
+ *,
181
+ account_base: str | None = None,
182
+ ) -> dict[str, str]:
183
+ """Return HTTP auth headers for a request.
184
+
185
+ Priority:
186
+ 1. ``api_key_override`` (from ``--api-key`` flag or ``NEPHER_API_KEY`` env var)
187
+ 2. Stored JWT (auto-refreshed if expired)
188
+ 3. Stored API key (fallback when no JWT)
189
+ 4. Empty dict — command must handle the unauthenticated case.
190
+ """
191
+ from nepher_cli.config import ACCOUNT_BACKEND
192
+
193
+ base = account_base or ACCOUNT_BACKEND
194
+
195
+ if api_key_override:
196
+ return {"X-API-Key": api_key_override}
197
+
198
+ creds = load_credentials()
199
+ if not creds:
200
+ return {}
201
+
202
+ access_token = creds.get("access_token")
203
+ refresh_token = creds.get("refresh_token")
204
+ api_key = creds.get("api_key")
205
+
206
+ if access_token and not _is_expired(creds):
207
+ return {"Authorization": f"Bearer {access_token}"}
208
+
209
+ if refresh_token:
210
+ refreshed = _refresh_access_token(refresh_token, base)
211
+ if refreshed and refreshed.get("access_token"):
212
+ new_access = refreshed["access_token"]
213
+ expires_in = refreshed.get("expires_in", 86400)
214
+ new_expires_at = int(time.time()) + expires_in
215
+ meta = _read_json()
216
+ meta["expires_at"] = new_expires_at
217
+ if meta.get("keyring"):
218
+ _kr_set(_KEYRING_ACCESS, new_access)
219
+ else:
220
+ meta["access_token"] = new_access
221
+ _write_json(meta)
222
+ return {"Authorization": f"Bearer {new_access}"}
223
+
224
+ if api_key:
225
+ return {"X-API-Key": api_key}
226
+
227
+ return {}
228
+
229
+
230
+ def get_stored_api_key() -> str | None:
231
+ """Return the stored raw API key, or None."""
232
+ creds = load_credentials()
233
+ if creds:
234
+ return creds.get("api_key")
235
+ return None
236
+
237
+
238
+ def whoami_from_cache() -> dict[str, Any] | None:
239
+ """Return user metadata from the credentials cache without a network call."""
240
+ creds = load_credentials()
241
+ if creds:
242
+ return creds.get("user")
243
+ return None
@@ -0,0 +1,76 @@
1
+ """Shared HTTP helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+
11
+ def parse_error_body(text: str) -> str | None:
12
+ try:
13
+ data = json.loads(text)
14
+ except json.JSONDecodeError:
15
+ return None
16
+ if not isinstance(data, dict):
17
+ return None
18
+ if data.get("message"):
19
+ return str(data["message"])
20
+ if "detail" in data:
21
+ detail = data["detail"]
22
+ return str(detail) if detail is not None else None
23
+ if "error" in data:
24
+ err = data["error"]
25
+ if err == "http_error" and data.get("message"):
26
+ return str(data["message"])
27
+ return str(err) if err is not None else None
28
+ return None
29
+
30
+
31
+ def request_json(
32
+ client: httpx.Client,
33
+ method: str,
34
+ url: str,
35
+ *,
36
+ json_body: dict[str, Any] | None = None,
37
+ headers: dict[str, str] | None = None,
38
+ ) -> httpx.Response:
39
+ return client.request(method, url, json=json_body, headers=headers, timeout=120.0)
40
+
41
+
42
+ def authed_get(url: str, *, api_key: str | None = None, params: dict[str, Any] | None = None) -> httpx.Response:
43
+ """GET with auth headers resolved from credential store or override."""
44
+ from nepher_cli.core.credentials import get_auth_headers
45
+
46
+ headers = get_auth_headers(api_key)
47
+ with httpx.Client() as client:
48
+ return client.get(url, headers=headers, params=params, timeout=60.0)
49
+
50
+
51
+ def authed_post(
52
+ url: str,
53
+ *,
54
+ api_key: str | None = None,
55
+ json_body: dict[str, Any] | None = None,
56
+ data: dict[str, str] | None = None,
57
+ files: dict[str, Any] | None = None,
58
+ timeout: float = 120.0,
59
+ ) -> httpx.Response:
60
+ """POST with auth headers resolved from credential store or override."""
61
+ from nepher_cli.core.credentials import get_auth_headers
62
+
63
+ headers = get_auth_headers(api_key)
64
+ with httpx.Client() as client:
65
+ if files is not None:
66
+ return client.post(url, headers=headers, data=data, files=files, timeout=timeout)
67
+ return client.post(url, headers=headers, json=json_body, timeout=timeout)
68
+
69
+
70
+ def authed_delete(url: str, *, api_key: str | None = None) -> httpx.Response:
71
+ """DELETE with auth headers resolved from credential store or override."""
72
+ from nepher_cli.core.credentials import get_auth_headers
73
+
74
+ headers = get_auth_headers(api_key)
75
+ with httpx.Client() as client:
76
+ return client.delete(url, headers=headers, timeout=30.0)
@@ -0,0 +1 @@
1
+ """EnvHub support code shared by ``npcli envhub`` commands."""
@@ -0,0 +1,56 @@
1
+ """Local EnvHub bundle cache paths — aligned with the ``nepher`` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from nepher_cli.envhub.config import DEFAULT_CONFIG, load_config_file
9
+
10
+ _DEFAULT_CACHE_DIR = Path.home() / ".nepher" / "cache"
11
+
12
+
13
+ def _nepher_cache_dir(category: str | None = None) -> Path | None:
14
+ try:
15
+ from nepher.config import get_config
16
+
17
+ return get_config().get_cache_dir(category=category)
18
+ except ImportError:
19
+ return None
20
+
21
+
22
+ def resolve_cache_dir(cache_dir: str | Path | None = None, category: str | None = None) -> Path:
23
+ """Resolve the local bundle cache directory (same rules as ``nepher``)."""
24
+ if cache_dir:
25
+ return Path(cache_dir).expanduser().resolve()
26
+
27
+ nepher_dir = _nepher_cache_dir(category)
28
+ if nepher_dir is not None:
29
+ return nepher_dir
30
+
31
+ env_override = os.getenv("NEPHER_CACHE_DIR")
32
+ if env_override:
33
+ return Path(env_override).expanduser().resolve()
34
+
35
+ config = {**DEFAULT_CONFIG, **load_config_file()}
36
+ if category:
37
+ cat_config = config.get("categories", {}).get(category, {})
38
+ path_str = cat_config.get("cache_dir") or config.get("cache_dir")
39
+ else:
40
+ path_str = config.get("cache_dir")
41
+
42
+ if path_str:
43
+ return Path(path_str).expanduser().resolve()
44
+ return _DEFAULT_CACHE_DIR.resolve()
45
+
46
+
47
+ def is_cached_env(path: Path) -> bool:
48
+ """Return True when ``path`` is a cached environment bundle."""
49
+ return path.is_dir() and (path / "manifest.yaml").exists()
50
+
51
+
52
+ def list_cached_env_dirs(root: Path) -> list[Path]:
53
+ """List cached environment directories under ``root``."""
54
+ if not root.exists():
55
+ return []
56
+ return sorted(p for p in root.iterdir() if is_cached_env(p))
@@ -0,0 +1,176 @@
1
+ """EnvHub configuration — aligned with the ``nepher`` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ DEFAULT_CONFIG: dict[str, Any] = {
11
+ "api_url": "https://envhub-api.nepher.ai",
12
+ "api_key": None,
13
+ "cache_dir": "~/.nepher/cache",
14
+ "default_category": None,
15
+ "categories": {},
16
+ }
17
+
18
+ LIST_KEYS = ("api_url", "cache_dir", "default_category")
19
+
20
+
21
+ def find_config_file() -> Path | None:
22
+ """Return the active nepher config file path, if any."""
23
+ cwd_config = Path.cwd() / ".nepherrc"
24
+ if cwd_config.exists():
25
+ return cwd_config
26
+ home_config = Path.home() / ".nepher" / "config.toml"
27
+ if home_config.exists():
28
+ return home_config
29
+ return None
30
+
31
+
32
+ def load_config_file() -> dict[str, Any]:
33
+ """Load values from the nepher config file (no defaults or env overrides)."""
34
+ config_file = find_config_file()
35
+ if not config_file:
36
+ return {}
37
+
38
+ try:
39
+ if config_file.suffix == ".toml":
40
+ try:
41
+ import tomllib
42
+ except ImportError:
43
+ try:
44
+ import tomli as tomllib # type: ignore[no-redef]
45
+ except ImportError:
46
+ return {}
47
+ with open(config_file, "rb") as f:
48
+ data = tomllib.load(f)
49
+ return data if isinstance(data, dict) else {}
50
+ if config_file.suffix == ".json" or config_file.name == ".nepherrc":
51
+ with open(config_file, encoding="utf-8") as f:
52
+ data = json.load(f)
53
+ return data if isinstance(data, dict) else {}
54
+ except Exception:
55
+ return {}
56
+ return {}
57
+
58
+
59
+ def _apply_env_overrides(config: dict[str, Any]) -> dict[str, Any]:
60
+ merged = dict(config)
61
+ if os.getenv("ENVHUB_API_URL"):
62
+ merged["api_url"] = os.getenv("ENVHUB_API_URL")
63
+ if os.getenv("NEPHER_API_KEY"):
64
+ merged["api_key"] = os.getenv("NEPHER_API_KEY")
65
+ if os.getenv("NEPHER_CACHE_DIR"):
66
+ merged["cache_dir"] = os.getenv("NEPHER_CACHE_DIR")
67
+ return merged
68
+
69
+
70
+ def _fallback_get(key: str, default: Any = None) -> Any:
71
+ config = _apply_env_overrides({**DEFAULT_CONFIG, **load_config_file()})
72
+ keys = key.split(".")
73
+ value: Any = config
74
+ for part in keys:
75
+ if isinstance(value, dict) and part in value:
76
+ value = value[part]
77
+ else:
78
+ return default
79
+ return value
80
+
81
+
82
+ def get_value(key: str, default: Any = None) -> Any:
83
+ """Return an effective configuration value."""
84
+ try:
85
+ from nepher.config import get_config
86
+
87
+ return get_config().get(key, default)
88
+ except ImportError:
89
+ return _fallback_get(key, default)
90
+
91
+
92
+ def list_values() -> dict[str, Any]:
93
+ """Return the standard keys shown by ``nepher config list``."""
94
+ return {key: get_value(key) for key in LIST_KEYS}
95
+
96
+
97
+ def parse_config_value(value: str) -> Any:
98
+ """Parse a CLI string into a config value."""
99
+ if value.lower() in ("true", "false"):
100
+ return value.lower() == "true"
101
+ if value.isdigit():
102
+ return int(value)
103
+ if value.replace(".", "", 1).isdigit():
104
+ return float(value)
105
+ return value
106
+
107
+
108
+ def set_value(key: str, value: Any) -> None:
109
+ """Persist a configuration value."""
110
+ try:
111
+ from nepher.config import set_config
112
+
113
+ set_config(key, value, save=True)
114
+ return
115
+ except ImportError:
116
+ pass
117
+
118
+ config_path = Path.home() / ".nepher" / "config.toml"
119
+ config_path.parent.mkdir(parents=True, exist_ok=True)
120
+ data = load_config_file()
121
+ parts = key.split(".")
122
+ target = data
123
+ for part in parts[:-1]:
124
+ nested = target.get(part)
125
+ if not isinstance(nested, dict):
126
+ nested = {}
127
+ target[part] = nested
128
+ target = nested
129
+ target[parts[-1]] = value
130
+
131
+ try:
132
+ import tomli_w
133
+
134
+ with open(config_path, "wb") as f:
135
+ tomli_w.dump(data, f)
136
+ except ImportError:
137
+ with open(config_path.with_suffix(".json"), "w", encoding="utf-8") as f:
138
+ json.dump(data, f, indent=2)
139
+
140
+
141
+ def reset_config() -> bool:
142
+ """Delete the on-disk config file. Returns True when a file was removed."""
143
+ try:
144
+ from nepher.config import get_config
145
+
146
+ config = get_config()
147
+ config_file = getattr(config, "_config_file", None)
148
+ if config_file and config_file.exists():
149
+ config_file.unlink()
150
+ return True
151
+ return False
152
+ except ImportError:
153
+ pass
154
+
155
+ removed = False
156
+ for path in (Path.cwd() / ".nepherrc", Path.home() / ".nepher" / "config.toml"):
157
+ if path.exists():
158
+ path.unlink()
159
+ removed = True
160
+ json_path = Path.home() / ".nepher" / "config.json"
161
+ if json_path.exists():
162
+ json_path.unlink()
163
+ removed = True
164
+ return removed
165
+
166
+
167
+ def mask_secret(key: str, value: Any) -> Any:
168
+ """Mask secret keys for display."""
169
+ if value is None or not isinstance(value, str):
170
+ return value
171
+ lower = key.lower()
172
+ if any(token in lower for token in ("api_key", "secret", "password", "token")):
173
+ if len(value) < 8:
174
+ return "***"
175
+ return f"{value[:4]}...{value[-4:]}"
176
+ return value
nepher_cli/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ # tournament logic package — agent_check, packer, api, wallet
@@ -0,0 +1,60 @@
1
+ """Local agent directory structure check — no external dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ # Required items: relative path → "file" or "directory"
8
+ _REQUIRED: dict[str, str] = {
9
+ "best_policy": "directory",
10
+ "best_policy/best_policy.pt": "file",
11
+ "source": "directory",
12
+ }
13
+
14
+ # Recommended (absent ones produce warnings, not errors)
15
+ _RECOMMENDED: dict[str, str] = {
16
+ "scripts/list_envs.py": "file",
17
+ "scripts/rsl_rl/play.py": "file",
18
+ }
19
+
20
+
21
+ def check_agent_structure(
22
+ agent_path: Path,
23
+ ) -> tuple[bool, list[str], list[str]]:
24
+ """Check whether *agent_path* meets the expected agent layout.
25
+
26
+ Returns:
27
+ (is_valid, errors, warnings)
28
+
29
+ * ``is_valid`` – True when all required items are present and correct.
30
+ * ``errors`` – Required items that are missing or the wrong type.
31
+ * ``warnings`` – Recommended items that are absent (non-blocking).
32
+ """
33
+ errors: list[str] = []
34
+ warnings: list[str] = []
35
+
36
+ if not agent_path.exists():
37
+ return False, [f"Path does not exist: {agent_path}"], warnings
38
+
39
+ if not agent_path.is_dir():
40
+ return False, [f"Not a directory: {agent_path}"], warnings
41
+
42
+ for rel_path, item_type in _REQUIRED.items():
43
+ full_path = agent_path / rel_path
44
+ if not full_path.exists():
45
+ errors.append(f"Required {item_type} missing: {rel_path}")
46
+ elif item_type == "directory" and not full_path.is_dir():
47
+ errors.append(f"Expected directory but found file: {rel_path}")
48
+ elif item_type == "file" and not full_path.is_file():
49
+ errors.append(f"Expected file but found directory: {rel_path}")
50
+
51
+ source_dir = agent_path / "source"
52
+ if source_dir.exists() and source_dir.is_dir():
53
+ if not any(d.is_dir() for d in source_dir.iterdir()):
54
+ errors.append("source/ directory must contain at least one task module subdirectory")
55
+
56
+ for rel_path, item_type in _RECOMMENDED.items():
57
+ if not (agent_path / rel_path).exists():
58
+ warnings.append(f"Recommended {item_type} missing: {rel_path}")
59
+
60
+ return len(errors) == 0, errors, warnings