dsh-scnet 0.4.2 → 0.5.0

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 (38) hide show
  1. package/README.md +20 -1
  2. package/README_CN.md +20 -1
  3. package/docs/installation.md +10 -0
  4. package/docs/installation.zh-CN.md +13 -1
  5. package/docs/testing.md +11 -0
  6. package/docs/testing.zh-CN.md +10 -0
  7. package/index.mjs +184 -0
  8. package/package.json +4 -2
  9. package/skills/scnet-hpc/SKILL.md +63 -105
  10. package/skills/scnet-hpc/VERSION +1 -0
  11. package/skills/scnet-hpc/clusters/_template.conf +10 -4
  12. package/skills/scnet-hpc/references/adding-cluster.md +6 -1
  13. package/skills/scnet-hpc/references/backends.md +177 -0
  14. package/skills/scnet-hpc/references/notebook.md +60 -0
  15. package/skills/scnet-hpc/references/openapi.md +140 -0
  16. package/skills/scnet-hpc/references/quickstart-en.md +22 -0
  17. package/skills/scnet-hpc/references/setup.md +4 -0
  18. package/skills/scnet-hpc/scripts/__pycache__/compute-probe.cpython-312.pyc +0 -0
  19. package/skills/scnet-hpc/scripts/__pycache__/scnet.cpython-312.pyc +0 -0
  20. package/skills/scnet-hpc/scripts/__pycache__/scnet_config.cpython-312.pyc +0 -0
  21. package/skills/scnet-hpc/scripts/__pycache__/scnet_credentials.cpython-312.pyc +0 -0
  22. package/skills/scnet-hpc/scripts/__pycache__/scnet_version.cpython-312.pyc +0 -0
  23. package/skills/scnet-hpc/scripts/scnet.py +1573 -0
  24. package/skills/scnet-hpc/scripts/scnet_backends/__init__.py +53 -0
  25. package/skills/scnet-hpc/scripts/scnet_backends/base.py +64 -0
  26. package/skills/scnet-hpc/scripts/scnet_backends/external.py +92 -0
  27. package/skills/scnet-hpc/scripts/scnet_backends/openapi.py +771 -0
  28. package/skills/scnet-hpc/scripts/scnet_backends/profile.py +78 -0
  29. package/skills/scnet-hpc/scripts/scnet_backends/ssh.py +258 -0
  30. package/skills/scnet-hpc/scripts/scnet_config.py +175 -0
  31. package/skills/scnet-hpc/scripts/scnet_credentials.py +160 -0
  32. package/skills/scnet-hpc/scripts/scnet_sdk/__init__.py +11 -0
  33. package/skills/scnet-hpc/scripts/scnet_sdk/cli.py +394 -0
  34. package/skills/scnet-hpc/scripts/scnet_sdk/client.py +240 -0
  35. package/skills/scnet-hpc/scripts/scnet_sdk/notebook.py +379 -0
  36. package/skills/scnet-hpc/scripts/scnet_version.py +20 -0
  37. package/skills/scnet-hpc/scripts/setup-ssh.sh +33 -1
  38. package/skills/scnet-hpc/scripts/setup.sh +471 -0
@@ -0,0 +1,78 @@
1
+ """Read the repository's simple shell-style cluster profiles safely."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ from pathlib import Path
8
+
9
+ from .base import BackendError
10
+
11
+
12
+ _ASSIGNMENT = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$")
13
+
14
+
15
+ def parse_profile(path: Path) -> dict[str, str]:
16
+ values: dict[str, str] = {}
17
+ logical_lines: list[tuple[int, str]] = []
18
+ pending = ""
19
+ pending_line = 0
20
+ for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
21
+ if not pending:
22
+ pending_line = line_number
23
+ if raw_line.endswith("\\"):
24
+ pending += raw_line[:-1]
25
+ continue
26
+ logical_lines.append((pending_line, pending + raw_line))
27
+ pending = ""
28
+ if pending:
29
+ raise BackendError(f"{path}:{pending_line}: unfinished line continuation")
30
+
31
+ for line_number, raw_line in logical_lines:
32
+ line = raw_line.strip()
33
+ if not line or line.startswith("#"):
34
+ continue
35
+ match = _ASSIGNMENT.match(line)
36
+ if not match:
37
+ raise BackendError(f"{path}:{line_number}: unsupported profile syntax")
38
+ key, raw_value = match.groups()
39
+ lexer = shlex.shlex(raw_value, posix=True)
40
+ lexer.whitespace_split = True
41
+ lexer.commenters = "#"
42
+ parts = list(lexer)
43
+ if len(parts) > 1:
44
+ raise BackendError(f"{path}:{line_number}: profile value must be scalar")
45
+ values[key] = parts[0] if parts else ""
46
+ return values
47
+
48
+
49
+ def list_profiles(repo_root: Path) -> list[dict[str, str]]:
50
+ cluster_dir = repo_root / "clusters"
51
+ result: list[dict[str, str]] = []
52
+ for path in sorted(cluster_dir.glob("*.conf")):
53
+ if path.name.startswith("_"):
54
+ continue
55
+ profile = parse_profile(path)
56
+ result.append(
57
+ {
58
+ "name": path.stem,
59
+ "cluster_id": profile.get("CLUSTER_ID", path.stem),
60
+ "description": profile.get("CLUSTER_DESC", ""),
61
+ "default_backend": profile.get("DEFAULT_BACKEND", "ssh"),
62
+ }
63
+ )
64
+ return result
65
+
66
+
67
+ def load_profile(repo_root: Path, name: str | None) -> tuple[str | None, dict[str, str]]:
68
+ profiles = list_profiles(repo_root)
69
+ if name:
70
+ path = repo_root / "clusters" / f"{name}.conf"
71
+ if not path.is_file():
72
+ available = ", ".join(item["name"] for item in profiles) or "(none)"
73
+ raise BackendError(f"unknown cluster profile {name!r}; available: {available}")
74
+ return name, parse_profile(path)
75
+ if len(profiles) == 1:
76
+ selected = profiles[0]["name"]
77
+ return selected, parse_profile(repo_root / "clusters" / f"{selected}.conf")
78
+ return None, {}
@@ -0,0 +1,258 @@
1
+ """SSH/Slurm backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ from .base import Backend, BackendError, require_option
11
+ from .profile import list_profiles
12
+
13
+
14
+ def _split_pipe(line: str, fields: list[str]) -> dict[str, str]:
15
+ values = line.rstrip("\n").split("|")
16
+ values += [""] * max(0, len(fields) - len(values))
17
+ return dict(zip(fields, values))
18
+
19
+
20
+ class SSHBackend(Backend):
21
+ name = "ssh"
22
+ capabilities = frozenset(
23
+ {
24
+ "clusters",
25
+ "queues",
26
+ "limits",
27
+ "job",
28
+ "logs",
29
+ "submit",
30
+ "cancel",
31
+ "upload",
32
+ "download",
33
+ "exec",
34
+ }
35
+ )
36
+
37
+ def _alias(self) -> str:
38
+ alias = self.context.profile.get("CLUSTER_ID")
39
+ if not alias:
40
+ raise BackendError("SSH operations require --cluster and a CLUSTER_ID profile field")
41
+ return alias
42
+
43
+ def _run(self, argv: list[str], *, input_text: str | None = None) -> str:
44
+ try:
45
+ completed = subprocess.run(
46
+ argv,
47
+ input=input_text,
48
+ text=True,
49
+ stdout=subprocess.PIPE,
50
+ stderr=subprocess.PIPE,
51
+ timeout=self.context.timeout,
52
+ check=False,
53
+ )
54
+ except (OSError, subprocess.TimeoutExpired) as exc:
55
+ raise BackendError(str(exc)) from exc
56
+ if completed.returncode != 0:
57
+ detail = completed.stderr.strip() or completed.stdout.strip()
58
+ raise BackendError(
59
+ f"command failed with exit {completed.returncode}: {detail or argv[0]}"
60
+ )
61
+ return completed.stdout
62
+
63
+ def _ssh(self, remote_command: str) -> str:
64
+ return self._run(
65
+ [
66
+ "ssh",
67
+ "-o",
68
+ "BatchMode=yes",
69
+ "-o",
70
+ f"ConnectTimeout={min(self.context.timeout, 30)}",
71
+ self._alias(),
72
+ remote_command,
73
+ ]
74
+ )
75
+
76
+ def op_clusters(self, options: Mapping[str, Any]) -> Any:
77
+ del options
78
+ return list_profiles(self.context.repo_root)
79
+
80
+ def op_queues(self, options: Mapping[str, Any]) -> Any:
81
+ del options
82
+ output = self._ssh(
83
+ "LC_ALL=C sinfo -h -o '%P|%a|%l|%D|%C|%G'"
84
+ )
85
+ fields = [
86
+ "partition",
87
+ "availability",
88
+ "max_walltime",
89
+ "nodes",
90
+ "cpu_state",
91
+ "gres",
92
+ ]
93
+ rows = [_split_pipe(line, fields) for line in output.splitlines() if line.strip()]
94
+ for row in rows:
95
+ row["partition"] = row["partition"].rstrip("*")
96
+ return rows
97
+
98
+ def op_limits(self, options: Mapping[str, Any]) -> Any:
99
+ partition = (
100
+ options.get("partition")
101
+ or self.context.profile.get("PARTITION")
102
+ or self.context.profile.get("PARTITION_CPU")
103
+ )
104
+ if not partition:
105
+ raise BackendError("limits requires --partition or a PARTITION profile field")
106
+ if not str(partition).replace("-", "").replace("_", "").isalnum():
107
+ raise BackendError("partition contains unsafe characters")
108
+ output = self._ssh(
109
+ f"LC_ALL=C scontrol show partition {shlex.quote(str(partition))} -o"
110
+ ).strip()
111
+ parsed: dict[str, str] = {"partition": str(partition)}
112
+ for token in output.split():
113
+ if "=" in token:
114
+ key, value = token.split("=", 1)
115
+ parsed[key] = value
116
+ keep = {
117
+ "partition",
118
+ "State",
119
+ "TotalNodes",
120
+ "TotalCPUs",
121
+ "DefMemPerCPU",
122
+ "DefMemPerNode",
123
+ "MaxMemPerCPU",
124
+ "MaxMemPerNode",
125
+ "MaxTime",
126
+ "MinNodes",
127
+ "MaxNodes",
128
+ "AllowAccounts",
129
+ "AllowQos",
130
+ "QoS",
131
+ "TRES",
132
+ }
133
+ return {key: value for key, value in parsed.items() if key in keep}
134
+
135
+ def op_job(self, options: Mapping[str, Any]) -> Any:
136
+ job_id = str(require_option(options, "job_id"))
137
+ if not job_id.replace("_", "").isalnum():
138
+ raise BackendError("job id contains unsafe characters")
139
+ fields = [
140
+ "job_id",
141
+ "state",
142
+ "partition",
143
+ "elapsed",
144
+ "time_limit",
145
+ "nodes",
146
+ "cpus",
147
+ "reason_or_nodes",
148
+ ]
149
+ output = self._ssh(
150
+ "LC_ALL=C squeue -h "
151
+ f"-j {shlex.quote(job_id)} -o '%i|%T|%P|%M|%l|%D|%C|%R'"
152
+ ).strip()
153
+ if output:
154
+ return _split_pipe(output.splitlines()[0], fields)
155
+ output = self._ssh(
156
+ "LC_ALL=C sacct -n -X "
157
+ f"-j {shlex.quote(job_id)} "
158
+ "--format=JobIDRaw,State,Partition,Elapsed,Timelimit,NNodes,NCPUS,ExitCode -P"
159
+ ).strip()
160
+ if not output:
161
+ raise BackendError(f"job {job_id!r} was not found")
162
+ history_fields = fields[:-1] + ["exit_code"]
163
+ return _split_pipe(output.splitlines()[0], history_fields)
164
+
165
+ def op_logs(self, options: Mapping[str, Any]) -> Any:
166
+ path = str(require_option(options, "path"))
167
+ if not path.startswith("/") or "\n" in path or "\x00" in path:
168
+ raise BackendError("remote log path must be a safe absolute path")
169
+ lines = int(options.get("lines") or 200)
170
+ if lines < 1 or lines > 10000:
171
+ raise BackendError("--lines must be between 1 and 10000")
172
+ direction = options.get("direction") or "tail"
173
+ command = "tail" if direction == "tail" else "head"
174
+ output = self._ssh(
175
+ f"{command} -n {lines} -- {shlex.quote(path)}"
176
+ )
177
+ return {"path": path, "direction": direction, "lines": output.splitlines()}
178
+
179
+ def op_submit(self, options: Mapping[str, Any]) -> Any:
180
+ remote_path = str(require_option(options, "remote_path"))
181
+ if not remote_path.startswith("/") or "\n" in remote_path:
182
+ raise BackendError("SSH submit requires a safe absolute --remote-path")
183
+ output = self._ssh(
184
+ f"sbatch --parsable -- {shlex.quote(remote_path)}"
185
+ ).strip()
186
+ return {"job_id": output.split(";", 1)[0], "raw": output}
187
+
188
+ def preview_submit(self, options: Mapping[str, Any]) -> Any:
189
+ remote_path = str(require_option(options, "remote_path"))
190
+ if not remote_path.startswith("/") or "\n" in remote_path:
191
+ raise BackendError("SSH submit requires a safe absolute --remote-path")
192
+ return {
193
+ "backend": self.name,
194
+ "operation": "submit",
195
+ "command": [
196
+ "ssh",
197
+ self._alias(),
198
+ "sbatch --parsable -- " + shlex.quote(remote_path),
199
+ ],
200
+ "remote_path": remote_path,
201
+ }
202
+
203
+ def op_cancel(self, options: Mapping[str, Any]) -> Any:
204
+ job_id = str(require_option(options, "job_id"))
205
+ if not job_id.replace("_", "").isalnum():
206
+ raise BackendError("job id contains unsafe characters")
207
+ self._ssh(f"scancel -- {shlex.quote(job_id)}")
208
+ return {"job_id": job_id, "cancelled": True}
209
+
210
+ def preview_cancel(self, options: Mapping[str, Any]) -> Any:
211
+ job_id = str(require_option(options, "job_id"))
212
+ if not job_id.replace("_", "").isalnum():
213
+ raise BackendError("job id contains unsafe characters")
214
+ return {
215
+ "backend": self.name,
216
+ "operation": "cancel",
217
+ "command": ["ssh", self._alias(), "scancel -- " + shlex.quote(job_id)],
218
+ "job_id": job_id,
219
+ }
220
+
221
+ def op_upload(self, options: Mapping[str, Any]) -> Any:
222
+ local_path = Path(str(require_option(options, "local_path"))).expanduser()
223
+ remote_path = str(require_option(options, "remote_path"))
224
+ if not local_path.is_file():
225
+ raise BackendError(f"local file does not exist: {local_path}")
226
+ if not remote_path.startswith("/") or "\n" in remote_path:
227
+ raise BackendError("remote path must be a safe absolute path")
228
+ remote_q = shlex.quote(remote_path)
229
+ name_q = shlex.quote(local_path.name)
230
+ check = self._ssh(
231
+ f"target={remote_q}; "
232
+ f"if test -d \"$target\"; then target=\"$target\"/{name_q}; fi; "
233
+ "if test -e \"$target\"; then state=EXISTS; else state=MISSING; fi; "
234
+ "printf '%s\\n%s\\n' \"$state\" \"$target\""
235
+ ).splitlines()
236
+ state = check[0] if check else ""
237
+ destination = check[1] if len(check) > 1 else remote_path
238
+ if state == "EXISTS" and not options.get("cover"):
239
+ raise BackendError("remote path already exists; pass --cover to overwrite it")
240
+ self._run(["scp", str(local_path), f"{self._alias()}:{remote_path}"])
241
+ return {"local_path": str(local_path), "remote_path": destination}
242
+
243
+ def op_download(self, options: Mapping[str, Any]) -> Any:
244
+ remote_path = str(require_option(options, "remote_path"))
245
+ local_path = Path(str(require_option(options, "local_path"))).expanduser()
246
+ if not remote_path.startswith("/") or "\n" in remote_path:
247
+ raise BackendError("remote path must be a safe absolute path")
248
+ if local_path.exists() and not options.get("cover"):
249
+ raise BackendError("local path already exists; pass --cover to overwrite it")
250
+ self._run(["scp", "-r", f"{self._alias()}:{remote_path}", str(local_path)])
251
+ return {"remote_path": remote_path, "local_path": str(local_path)}
252
+
253
+ def op_exec(self, options: Mapping[str, Any]) -> Any:
254
+ command = str(require_option(options, "command"))
255
+ if "\x00" in command:
256
+ raise BackendError("command contains a NUL byte")
257
+ output = self._ssh(command)
258
+ return {"stdout": output}
@@ -0,0 +1,175 @@
1
+ """User-local, non-secret configuration for the SCNet CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def config_root() -> Path:
14
+ base = os.environ.get("XDG_CONFIG_HOME")
15
+ if base:
16
+ return Path(base).expanduser() / "scnet-hpc"
17
+ return Path.home() / ".config" / "scnet-hpc"
18
+
19
+
20
+ def config_path() -> Path:
21
+ return config_root() / "config.json"
22
+
23
+
24
+ def ssh_profile_path(cluster: str) -> Path:
25
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", cluster):
26
+ raise ValueError("unsafe cluster profile name")
27
+ return config_root() / "ssh" / f"{cluster}.json"
28
+
29
+
30
+ def openapi_cache_path() -> Path:
31
+ return config_root() / "openapi.json"
32
+
33
+
34
+ def _read_json(path: Path) -> dict[str, Any]:
35
+ try:
36
+ data = json.loads(path.read_text(encoding="utf-8"))
37
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
38
+ return {}
39
+ return data if isinstance(data, dict) else {}
40
+
41
+
42
+ def load_user_config() -> dict[str, Any]:
43
+ data = _read_json(config_path())
44
+ ssh = data.setdefault("ssh", {})
45
+ if isinstance(ssh, dict):
46
+ clusters = ssh.setdefault("clusters", {})
47
+ if isinstance(clusters, dict):
48
+ ssh_dir = config_root() / "ssh"
49
+ for path in sorted(ssh_dir.glob("*.json")):
50
+ profile = _read_json(path)
51
+ if profile:
52
+ clusters[path.stem] = profile
53
+ openapi_cache = _read_json(openapi_cache_path())
54
+ if openapi_cache:
55
+ current = data.setdefault("openapi", {})
56
+ if isinstance(current, dict):
57
+ current.update(openapi_cache)
58
+ return data
59
+
60
+
61
+ def save_user_config(data: dict[str, Any]) -> Path:
62
+ path = config_path()
63
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
64
+ path.parent.chmod(0o700)
65
+ clean = json.loads(json.dumps(data, ensure_ascii=False))
66
+ cluster = str(clean.get("cluster") or "")
67
+ ssh = clean.get("ssh")
68
+ if isinstance(ssh, dict):
69
+ clusters = ssh.get("clusters")
70
+ if isinstance(clusters, dict):
71
+ for name, profile in clusters.items():
72
+ if isinstance(profile, dict):
73
+ save_ssh_profile(str(name), profile)
74
+ elif cluster and isinstance(ssh.get("username"), str):
75
+ save_ssh_profile(
76
+ cluster,
77
+ {
78
+ key: value
79
+ for key, value in ssh.items()
80
+ if key != "clusters"
81
+ },
82
+ )
83
+ openapi = clean.get("openapi")
84
+ if isinstance(openapi, dict):
85
+ _save_json_sidecar(openapi_cache_path(), openapi)
86
+ clean.pop("ssh", None)
87
+ clean.pop("openapi", None)
88
+ fd, temporary = tempfile.mkstemp(
89
+ prefix=".config.", suffix=".tmp", dir=str(path.parent)
90
+ )
91
+ try:
92
+ os.fchmod(fd, 0o600)
93
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
94
+ json.dump(clean, stream, ensure_ascii=False, indent=2, sort_keys=True)
95
+ stream.write("\n")
96
+ os.replace(temporary, path)
97
+ finally:
98
+ try:
99
+ os.unlink(temporary)
100
+ except FileNotFoundError:
101
+ pass
102
+ return path
103
+
104
+
105
+ def _save_json_sidecar(path: Path, data: dict[str, Any]) -> Path:
106
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
107
+ path.parent.chmod(0o700)
108
+ clean = json.loads(json.dumps(data, ensure_ascii=False))
109
+ fd, temporary = tempfile.mkstemp(
110
+ prefix=f".{path.stem}.", suffix=".tmp", dir=str(path.parent)
111
+ )
112
+ try:
113
+ os.fchmod(fd, 0o600)
114
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
115
+ json.dump(clean, stream, ensure_ascii=False, indent=2, sort_keys=True)
116
+ stream.write("\n")
117
+ os.replace(temporary, path)
118
+ finally:
119
+ try:
120
+ os.unlink(temporary)
121
+ except FileNotFoundError:
122
+ pass
123
+ return path
124
+
125
+
126
+ def save_ssh_profile(cluster: str, data: dict[str, Any]) -> Path:
127
+ return _save_json_sidecar(ssh_profile_path(cluster), data)
128
+
129
+
130
+ def reset_openapi_metadata() -> bool:
131
+ path = openapi_cache_path()
132
+ try:
133
+ path.unlink()
134
+ return True
135
+ except FileNotFoundError:
136
+ return False
137
+
138
+
139
+ def reset_ssh_metadata(cluster: str) -> bool:
140
+ path = ssh_profile_path(cluster)
141
+ try:
142
+ path.unlink()
143
+ return True
144
+ except FileNotFoundError:
145
+ return False
146
+
147
+
148
+ def reset_all_metadata() -> list[Path]:
149
+ removed: list[Path] = []
150
+ targets = [config_path(), openapi_cache_path()]
151
+ targets.extend(sorted((config_root() / "ssh").glob("*.json")))
152
+ for path in targets:
153
+ try:
154
+ path.unlink()
155
+ removed.append(path)
156
+ except FileNotFoundError:
157
+ pass
158
+ for directory in (config_root() / "ssh", config_root()):
159
+ try:
160
+ directory.rmdir()
161
+ except OSError:
162
+ pass
163
+ return removed
164
+
165
+
166
+ def redacted_config(data: dict[str, Any]) -> dict[str, Any]:
167
+ """Return a safe-to-display copy; this config should contain no secrets anyway."""
168
+ result = json.loads(json.dumps(data, ensure_ascii=False))
169
+ for section in ("openapi", "ssh"):
170
+ if isinstance(result.get(section), dict):
171
+ for key in list(result[section]):
172
+ lowered = key.lower()
173
+ if any(marker in lowered for marker in ("key", "token", "secret", "password")):
174
+ result[section][key] = "<not displayed>"
175
+ return result
@@ -0,0 +1,160 @@
1
+ """Secure OpenAPI credential discovery and storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ from typing import Any
11
+
12
+
13
+ SERVICE = "scnet-hpc-openapi"
14
+
15
+
16
+ class CredentialError(RuntimeError):
17
+ """A user-facing credential-store failure."""
18
+
19
+
20
+ def _valid(data: Any) -> bool:
21
+ return isinstance(data, dict) and all(
22
+ isinstance(data.get(key), str) and data[key]
23
+ for key in ("user", "access_key", "secret_key")
24
+ )
25
+
26
+
27
+ def environment_credentials() -> dict[str, str] | None:
28
+ data = {
29
+ "user": os.environ.get("SCNET_OPENAPI_USER", ""),
30
+ "access_key": os.environ.get("SCNET_OPENAPI_ACCESS_KEY", ""),
31
+ "secret_key": os.environ.get("SCNET_OPENAPI_SECRET_KEY", ""),
32
+ }
33
+ return data if _valid(data) else None
34
+
35
+
36
+ def secure_store_name() -> str | None:
37
+ if platform.system() == "Darwin" and shutil.which("security"):
38
+ return "macOS Keychain"
39
+ if shutil.which("secret-tool"):
40
+ return "Secret Service"
41
+ return None
42
+
43
+
44
+ def load_secure_credentials() -> dict[str, str] | None:
45
+ provider = secure_store_name()
46
+ if provider == "macOS Keychain":
47
+ command = ["security", "find-generic-password", "-s", SERVICE, "-w"]
48
+ elif provider == "Secret Service":
49
+ command = ["secret-tool", "lookup", "service", SERVICE]
50
+ else:
51
+ return None
52
+ completed = subprocess.run(
53
+ command,
54
+ text=True,
55
+ stdout=subprocess.PIPE,
56
+ stderr=subprocess.DEVNULL,
57
+ check=False,
58
+ )
59
+ if completed.returncode != 0 or not completed.stdout.strip():
60
+ return None
61
+ try:
62
+ data = json.loads(completed.stdout)
63
+ except json.JSONDecodeError:
64
+ return None
65
+ return data if _valid(data) else None
66
+
67
+
68
+ def load_openapi_credentials() -> tuple[dict[str, str] | None, str | None]:
69
+ data = environment_credentials()
70
+ if data:
71
+ return data, "environment"
72
+ data = load_secure_credentials()
73
+ if data:
74
+ return data, secure_store_name()
75
+ return None, None
76
+
77
+
78
+ def store_openapi_credentials(
79
+ user: str, access_key: str, secret_key: str
80
+ ) -> str:
81
+ data = {
82
+ "user": user,
83
+ "access_key": access_key,
84
+ "secret_key": secret_key,
85
+ }
86
+ if not _valid(data):
87
+ raise CredentialError("OpenAPI credentials are incomplete")
88
+ payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
89
+ provider = secure_store_name()
90
+ if provider == "macOS Keychain":
91
+ command = [
92
+ "security",
93
+ "add-generic-password",
94
+ "-U",
95
+ "-s",
96
+ SERVICE,
97
+ "-a",
98
+ user,
99
+ "-w",
100
+ payload,
101
+ ]
102
+ completed = subprocess.run(
103
+ command,
104
+ stdout=subprocess.DEVNULL,
105
+ stderr=subprocess.PIPE,
106
+ text=True,
107
+ check=False,
108
+ )
109
+ elif provider == "Secret Service":
110
+ command = [
111
+ "secret-tool",
112
+ "store",
113
+ "--label=SCNet OpenAPI",
114
+ "service",
115
+ SERVICE,
116
+ "user",
117
+ user,
118
+ ]
119
+ completed = subprocess.run(
120
+ command,
121
+ input=payload,
122
+ stdout=subprocess.DEVNULL,
123
+ stderr=subprocess.PIPE,
124
+ text=True,
125
+ check=False,
126
+ )
127
+ else:
128
+ raise CredentialError(
129
+ "no supported secure store; use SCNET_OPENAPI_USER, "
130
+ "SCNET_OPENAPI_ACCESS_KEY, and SCNET_OPENAPI_SECRET_KEY"
131
+ )
132
+ if completed.returncode != 0:
133
+ raise CredentialError(
134
+ completed.stderr.strip() or f"failed to write {provider}"
135
+ )
136
+ return str(provider)
137
+
138
+
139
+ def delete_openapi_credentials() -> tuple[bool, str | None]:
140
+ provider = secure_store_name()
141
+ if provider == "macOS Keychain":
142
+ command = ["security", "delete-generic-password", "-s", SERVICE]
143
+ elif provider == "Secret Service":
144
+ command = ["secret-tool", "clear", "service", SERVICE]
145
+ else:
146
+ return False, None
147
+ completed = subprocess.run(
148
+ command,
149
+ stdout=subprocess.DEVNULL,
150
+ stderr=subprocess.PIPE,
151
+ text=True,
152
+ check=False,
153
+ )
154
+ if completed.returncode == 0:
155
+ return True, provider
156
+ if load_secure_credentials() is None:
157
+ return False, provider
158
+ raise CredentialError(
159
+ completed.stderr.strip() or f"failed to clear {provider}"
160
+ )
@@ -0,0 +1,11 @@
1
+ """Small internal client used by CLI, plugins, and future MCP adapters."""
2
+
3
+ from .client import SCNetClient, canonical_signature, service_endpoint
4
+ from .notebook import NotebookService
5
+
6
+ __all__ = [
7
+ "NotebookService",
8
+ "SCNetClient",
9
+ "canonical_signature",
10
+ "service_endpoint",
11
+ ]