substrate-setup 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.
@@ -0,0 +1,259 @@
1
+ """Hermes Agent (NousResearch/hermes-agent) integration.
2
+
3
+ Config:
4
+ ~/.hermes/config.yaml — providers + model defaults
5
+ ~/.hermes/.env — secrets (SUBSTRATE_API_KEY)
6
+
7
+ Schema verified against upstream docs during plan-writing.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ruamel.yaml import YAML
16
+
17
+ from substrate_setup.agents.base import (
18
+ Agent,
19
+ AgentResult,
20
+ ConfigureContext,
21
+ ResultStatus,
22
+ )
23
+ from substrate_setup.backup import backup_once
24
+ from substrate_setup.markers import MARKER_KEY, MARKER_VALUE, is_substrate_managed
25
+
26
+ PROVIDER_NAME = "substrate"
27
+ ENV_VAR = "SUBSTRATE_API_KEY"
28
+
29
+
30
+ def _config_dir() -> Path:
31
+ return Path.home() / ".hermes"
32
+
33
+
34
+ def _config_yaml() -> Path:
35
+ return _config_dir() / "config.yaml"
36
+
37
+
38
+ def _env_file() -> Path:
39
+ return _config_dir() / ".env"
40
+
41
+
42
+ def _yaml() -> YAML:
43
+ y = YAML()
44
+ y.preserve_quotes = True
45
+ y.indent(mapping=2, sequence=4, offset=2)
46
+ return y
47
+
48
+
49
+ def _load_yaml(path: Path) -> dict[str, Any]:
50
+ if not path.exists():
51
+ return {}
52
+ text = path.read_text(encoding="utf-8")
53
+ result: dict[str, Any] = _yaml().load(text) or {}
54
+ return result
55
+
56
+
57
+ def _dump_yaml(path: Path, data: dict[str, Any]) -> None:
58
+ buf = io.StringIO()
59
+ _yaml().dump(data, buf)
60
+ path.write_text(buf.getvalue(), encoding="utf-8")
61
+
62
+
63
+ def _build_substrate_provider(ctx: ConfigureContext) -> dict[str, Any]:
64
+ models = {
65
+ entry.id: {"context_length": 200000}
66
+ for entry in ctx.catalog
67
+ }
68
+ return {
69
+ "name": PROVIDER_NAME,
70
+ "base_url": ctx.base_url,
71
+ "key_env": ENV_VAR,
72
+ "api_mode": "chat_completions",
73
+ "models": models,
74
+ MARKER_KEY: MARKER_VALUE,
75
+ }
76
+
77
+
78
+ def _merge_provider_models(
79
+ existing: dict[str, Any],
80
+ fresh: dict[str, Any],
81
+ *,
82
+ additive_only: bool,
83
+ ) -> dict[str, Any]:
84
+ """Merge the substrate provider entry, respecting additive-only on fallback."""
85
+ if additive_only:
86
+ merged_models = {**existing.get("models", {}), **fresh["models"]}
87
+ return {**fresh, "models": merged_models}
88
+ return fresh
89
+
90
+
91
+ def _read_env_lines(path: Path) -> list[str]:
92
+ if not path.exists():
93
+ return []
94
+ return path.read_text(encoding="utf-8").splitlines()
95
+
96
+
97
+ def _write_env_lines(path: Path, lines: list[str]) -> None:
98
+ path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
99
+
100
+
101
+ class HermesAgent(Agent):
102
+ name = "hermes"
103
+ pretty_name = "Hermes"
104
+
105
+ def detect(self) -> Path | None:
106
+ cfg = _config_yaml()
107
+ return cfg if cfg.exists() else None
108
+
109
+ def configure(self, ctx: ConfigureContext) -> AgentResult:
110
+ _config_dir().mkdir(parents=True, exist_ok=True)
111
+ backups: list[Path] = []
112
+ notes: list[str] = []
113
+
114
+ # ── config.yaml ──────────────────────────────────────────────
115
+ cfg_path = _config_yaml()
116
+ if cfg_path.exists():
117
+ backup = backup_once(cfg_path)
118
+ if backup is not None:
119
+ backups.append(backup)
120
+
121
+ payload = _load_yaml(cfg_path)
122
+ providers: list[dict[str, Any]] = payload.get("custom_providers") or []
123
+ new_provider = _build_substrate_provider(ctx)
124
+
125
+ existing_substrate = next(
126
+ (p for p in providers if p.get("name") == PROVIDER_NAME), None
127
+ )
128
+ if existing_substrate is not None:
129
+ merged = _merge_provider_models(
130
+ existing_substrate, new_provider, additive_only=(ctx.catalog_source == "fallback")
131
+ )
132
+ providers = [
133
+ merged if p.get("name") == PROVIDER_NAME else p for p in providers
134
+ ]
135
+ else:
136
+ providers.append(new_provider)
137
+
138
+ payload["custom_providers"] = providers
139
+
140
+ if not ctx.dry_run:
141
+ _dump_yaml(cfg_path, payload)
142
+
143
+ # ── .env ─────────────────────────────────────────────────────
144
+ env_path = _env_file()
145
+ lines = _read_env_lines(env_path)
146
+ existing_value = None
147
+ for line in lines:
148
+ if line.startswith(f"{ENV_VAR}="):
149
+ existing_value = line.split("=", 1)[1]
150
+ break
151
+
152
+ should_write_env = False
153
+ new_lines = list(lines)
154
+ if existing_value is None:
155
+ new_lines.append(f"{ENV_VAR}={ctx.api_key}")
156
+ should_write_env = True
157
+ elif existing_value.startswith("sk-substrate-"):
158
+ if existing_value != ctx.api_key:
159
+ new_lines = [
160
+ f"{ENV_VAR}={ctx.api_key}" if line.startswith(f"{ENV_VAR}=") else line
161
+ for line in lines
162
+ ]
163
+ should_write_env = True
164
+ else:
165
+ notes.append(
166
+ f"Hermes already has {ENV_VAR} set to a non-Substrate-shaped value; "
167
+ "please update manually."
168
+ )
169
+
170
+ if should_write_env:
171
+ if env_path.exists():
172
+ env_backup = backup_once(env_path)
173
+ if env_backup is not None:
174
+ backups.append(env_backup)
175
+ if not ctx.dry_run:
176
+ _write_env_lines(env_path, new_lines)
177
+
178
+ msg = f"{len(ctx.catalog)} models written to ~/.hermes/config.yaml"
179
+ if notes:
180
+ msg += "\n" + "\n".join(notes)
181
+ return AgentResult(ResultStatus.SUCCESS, msg, tuple(backups))
182
+
183
+ def verify(self, ctx: ConfigureContext) -> AgentResult:
184
+ cfg_path = _config_yaml()
185
+ if not cfg_path.exists():
186
+ return AgentResult(
187
+ ResultStatus.ERROR, "Hermes config.yaml does not exist"
188
+ )
189
+ payload = _load_yaml(cfg_path)
190
+ providers = payload.get("custom_providers") or []
191
+ substrate = next(
192
+ (p for p in providers if p.get("name") == PROVIDER_NAME), None
193
+ )
194
+ if substrate is None:
195
+ return AgentResult(
196
+ ResultStatus.ERROR, "No substrate provider entry found"
197
+ )
198
+ if not is_substrate_managed(substrate):
199
+ return AgentResult(
200
+ ResultStatus.ERROR,
201
+ "Substrate provider entry exists but is not managed by "
202
+ "substrate-setup (run configure to re-take ownership)",
203
+ )
204
+ catalog_ids = {e.id for e in ctx.catalog}
205
+ config_ids = set(substrate.get("models", {}).keys())
206
+ missing = catalog_ids - config_ids
207
+ stale = config_ids - catalog_ids
208
+ if missing or stale:
209
+ parts = []
210
+ if missing:
211
+ parts.append(f"missing: {sorted(missing)}")
212
+ if stale:
213
+ parts.append(f"stale: {sorted(stale)}")
214
+ return AgentResult(ResultStatus.ERROR, "; ".join(parts))
215
+ return AgentResult(ResultStatus.SUCCESS, "in sync")
216
+
217
+ def remove(self, ctx: ConfigureContext) -> AgentResult:
218
+ cfg_path = _config_yaml()
219
+ if not cfg_path.exists():
220
+ return AgentResult(ResultStatus.SKIPPED, "Hermes not configured")
221
+ backups: list[Path] = []
222
+ cfg_backup = backup_once(cfg_path)
223
+ if cfg_backup is not None:
224
+ backups.append(cfg_backup)
225
+ payload = _load_yaml(cfg_path)
226
+ providers = payload.get("custom_providers") or []
227
+ kept = [
228
+ p for p in providers
229
+ if not (p.get("name") == PROVIDER_NAME and is_substrate_managed(p))
230
+ ]
231
+ payload["custom_providers"] = kept
232
+ if not ctx.dry_run:
233
+ _dump_yaml(cfg_path, payload)
234
+
235
+ # Strip SUBSTRATE_API_KEY from .env only if it's currently a Substrate-shaped value.
236
+ env_path = _env_file()
237
+ if env_path.exists():
238
+ lines = _read_env_lines(env_path)
239
+ new_lines = []
240
+ stripped = False
241
+ for line in lines:
242
+ if line.startswith(f"{ENV_VAR}="):
243
+ value = line.split("=", 1)[1]
244
+ if value.startswith("sk-substrate-"):
245
+ stripped = True
246
+ continue
247
+ new_lines.append(line)
248
+ if stripped:
249
+ env_backup = backup_once(env_path)
250
+ if env_backup is not None:
251
+ backups.append(env_backup)
252
+ if not ctx.dry_run:
253
+ _write_env_lines(env_path, new_lines)
254
+
255
+ return AgentResult(
256
+ ResultStatus.SUCCESS,
257
+ "Removed substrate provider entry from Hermes",
258
+ tuple(backups),
259
+ )
@@ -0,0 +1,32 @@
1
+ """Backup-on-write helper.
2
+
3
+ One backup per (file, run). The first call backs up; subsequent calls
4
+ for the same path in the same Python process are no-ops returning the
5
+ first backup's path.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import time
11
+ from pathlib import Path
12
+
13
+ _backups_this_run: dict[Path, Path] = {}
14
+
15
+
16
+ def backup_once(target: Path) -> Path | None:
17
+ """Backup ``target`` to ``<target>.substrate-bak-<unix-ts>``.
18
+
19
+ Returns the backup path. If the target doesn't exist, returns None.
20
+ Within a single process, repeated calls for the same target return
21
+ the first backup's path without touching anything.
22
+ """
23
+ target = target.resolve()
24
+ if target in _backups_this_run:
25
+ return _backups_this_run[target]
26
+ if not target.exists():
27
+ return None
28
+ ts = int(time.time())
29
+ backup_path = target.parent / f"{target.name}.substrate-bak-{ts}"
30
+ shutil.copy2(target, backup_path)
31
+ _backups_this_run[target] = backup_path
32
+ return backup_path
@@ -0,0 +1,109 @@
1
+ """Catalog fetch — live /v1/models with bundled-snapshot fallback."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ import httpx
11
+
12
+ CONNECT_TIMEOUT_SEC = 5.0
13
+ READ_TIMEOUT_SEC = 10.0
14
+
15
+ _FALLBACK_PATH = Path(__file__).parent / "data" / "fallback_catalog.json"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CatalogEntry:
20
+ id: str
21
+ provider: str
22
+ display_name: str
23
+ description: str
24
+
25
+
26
+ class AuthError(Exception):
27
+ """The gateway rejected the API key (HTTP 401)."""
28
+
29
+
30
+ class CatalogUnavailableError(Exception):
31
+ """Live fetch failed AND the bundled snapshot is missing/corrupt."""
32
+
33
+
34
+ def _parse_payload(payload: dict[str, Any]) -> list[CatalogEntry]:
35
+ return [
36
+ CatalogEntry(
37
+ id=item["id"],
38
+ provider=item.get("owned_by", "unknown"),
39
+ display_name=item.get("display_name", item["id"]),
40
+ description=item.get("description", ""),
41
+ )
42
+ for item in payload.get("data", [])
43
+ ]
44
+
45
+
46
+ def _load_fallback() -> list[CatalogEntry]:
47
+ if not _FALLBACK_PATH.exists():
48
+ raise CatalogUnavailableError(
49
+ f"Bundled fallback catalog missing at {_FALLBACK_PATH}. "
50
+ "Reinstall substrate-setup."
51
+ )
52
+ try:
53
+ payload = json.loads(_FALLBACK_PATH.read_text(encoding="utf-8"))
54
+ except json.JSONDecodeError as exc:
55
+ raise CatalogUnavailableError(
56
+ f"Bundled fallback catalog at {_FALLBACK_PATH} is corrupt: {exc}"
57
+ ) from exc
58
+ return _parse_payload(payload)
59
+
60
+
61
+ def fetch_catalog(
62
+ *,
63
+ base_url: str,
64
+ api_key: str,
65
+ ) -> tuple[list[CatalogEntry], Literal["live", "fallback"]]:
66
+ """Fetch the model catalog. Returns (entries, source-tag).
67
+
68
+ Resolution order:
69
+ 1. live GET <base_url>/v1/models with Bearer <api_key>
70
+ 2. bundled fallback snapshot (with warning printed to stderr)
71
+
72
+ Raises:
73
+ AuthError: live fetch returned 401. Caller should exit 1.
74
+ CatalogUnavailableError: live fetch failed AND fallback is missing/corrupt.
75
+ Caller should exit 2.
76
+ """
77
+ # Tolerate users passing base URLs with or without a trailing /v1
78
+ normalized = base_url.rstrip("/")
79
+ if normalized.endswith("/v1"):
80
+ normalized = normalized[:-3]
81
+ url = f"{normalized}/v1/models"
82
+ timeout = httpx.Timeout(READ_TIMEOUT_SEC, connect=CONNECT_TIMEOUT_SEC)
83
+ try:
84
+ resp = httpx.get(
85
+ url,
86
+ headers={"Authorization": f"Bearer {api_key}"},
87
+ timeout=timeout,
88
+ )
89
+ except httpx.TransportError as exc:
90
+ print(
91
+ f"WARNING: could not reach {url} ({type(exc).__name__}); "
92
+ "using bundled catalog snapshot. Models may be out of date.",
93
+ file=sys.stderr,
94
+ )
95
+ return _load_fallback(), "fallback"
96
+
97
+ if resp.status_code == 401:
98
+ raise AuthError(
99
+ f"That key was rejected by {base_url}. Did you copy the full string?"
100
+ )
101
+ if resp.status_code >= 500:
102
+ print(
103
+ f"WARNING: gateway at {url} returned HTTP {resp.status_code}; "
104
+ "using bundled catalog snapshot. Models may be out of date.",
105
+ file=sys.stderr,
106
+ )
107
+ return _load_fallback(), "fallback"
108
+ resp.raise_for_status()
109
+ return _parse_payload(resp.json()), "live"
substrate_setup/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """CLI orchestration for substrate-setup.
2
+
3
+ Subcommands:
4
+ configure (default) detect agents and write Substrate config
5
+ verify report whether each detected agent is in sync with the catalog
6
+ remove strip substrate-managed entries from each detected agent
7
+ version print version
8
+
9
+ Common flags: --base-url, --agents-only, --dry-run, --quiet.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from collections.abc import Callable
16
+
17
+ from substrate_setup import __version__
18
+ from substrate_setup.agents import AGENT_NAMES, ALL_AGENTS
19
+ from substrate_setup.agents.base import Agent, AgentResult, ConfigureContext, ResultStatus
20
+ from substrate_setup.catalog import (
21
+ AuthError,
22
+ CatalogEntry,
23
+ CatalogUnavailableError,
24
+ fetch_catalog,
25
+ )
26
+ from substrate_setup.credentials import (
27
+ CredentialResolutionError,
28
+ InvalidKeyFormatError,
29
+ resolve_api_key,
30
+ )
31
+
32
+ EXIT_OK = 0
33
+ EXIT_AUTH = 1
34
+ EXIT_CATALOG = 2
35
+ EXIT_PARTIAL = 3
36
+ EXIT_BAD_FLAGS = 4
37
+
38
+ DEFAULT_BASE_URL = "https://substrate-solutions-api.fly.dev/v1"
39
+
40
+
41
+ def _common_flags_parser() -> argparse.ArgumentParser:
42
+ """Return a parser holding the flags shared by configure/verify/remove."""
43
+ p = argparse.ArgumentParser(add_help=False)
44
+ p.add_argument("--base-url", default=None, help="Override gateway base URL")
45
+ p.add_argument(
46
+ "--agents-only",
47
+ default=None,
48
+ help=f"Comma-separated subset of: {','.join(AGENT_NAMES)}",
49
+ )
50
+ p.add_argument("--dry-run", action="store_true", help="Mutate nothing")
51
+ p.add_argument("--quiet", action="store_true", help="Summary only")
52
+ return p
53
+
54
+
55
+ def build_parser() -> argparse.ArgumentParser:
56
+ common = _common_flags_parser()
57
+ parser = argparse.ArgumentParser(
58
+ prog="substrate-setup",
59
+ description="Configure local coding agents against a Substrate gateway.",
60
+ parents=[common],
61
+ )
62
+ sub = parser.add_subparsers(dest="command")
63
+ sub.add_parser("configure", parents=[common],
64
+ help="Default: detect agents and configure")
65
+ sub.add_parser("verify", parents=[common],
66
+ help="Read-only: check whether each agent is in sync")
67
+ sub.add_parser("remove", parents=[common],
68
+ help="Strip substrate-managed entries")
69
+ sub.add_parser("version", help="Print version and exit")
70
+ return parser
71
+
72
+
73
+ def _select_agents(filter_str: str | None) -> list[Agent]:
74
+ if filter_str is None:
75
+ return list(ALL_AGENTS)
76
+ wanted = {name.strip() for name in filter_str.split(",") if name.strip()}
77
+ unknown = wanted - set(AGENT_NAMES)
78
+ if unknown:
79
+ print(
80
+ f"Unknown agent(s) in --agents-only: {sorted(unknown)}. "
81
+ f"Valid: {AGENT_NAMES}",
82
+ file=sys.stderr,
83
+ )
84
+ sys.exit(EXIT_BAD_FLAGS)
85
+ return [a for a in ALL_AGENTS if a.name in wanted]
86
+
87
+
88
+ def _resolve_base_url(arg: str | None) -> str:
89
+ import os
90
+
91
+ if arg is not None:
92
+ return arg
93
+ return os.environ.get("SUBSTRATE_BASE_URL") or DEFAULT_BASE_URL
94
+
95
+
96
+ def _run_per_agent(
97
+ agents: list[Agent],
98
+ ctx: ConfigureContext,
99
+ method: Callable[[Agent], AgentResult],
100
+ *,
101
+ label: str,
102
+ quiet: bool,
103
+ ) -> int:
104
+ """Execute ``method`` on each detected agent. Print results. Return exit code."""
105
+ if not quiet:
106
+ print(f"{label} agents …")
107
+ print("Detected agents:")
108
+ for a in agents:
109
+ detected = a.detect()
110
+ tag = "[print-only handler]" if a.name == "cursor" else ""
111
+ mark = "✓" if detected else "·"
112
+ note = f"({detected})" if detected else "not installed"
113
+ print(f" {mark} {a.pretty_name:<12} {note} {tag}")
114
+ print()
115
+
116
+ error_count = 0
117
+ handled = 0
118
+ for a in agents:
119
+ detected = a.detect()
120
+ if not detected:
121
+ continue
122
+ handled += 1
123
+ try:
124
+ result = method(a)
125
+ except Exception as exc: # noqa: BLE001
126
+ result = AgentResult(ResultStatus.ERROR, f"unhandled error: {exc}")
127
+ if not quiet:
128
+ prefix = f"{label} {a.pretty_name:<10}"
129
+ if result.status == ResultStatus.ERROR:
130
+ print(f"{prefix} … ERROR: {result.message}")
131
+ elif result.status == ResultStatus.PRINT_ONLY:
132
+ print(f"{prefix} … printed walkthrough above.")
133
+ else:
134
+ print(f"{prefix} … {result.message}")
135
+ for backup in result.backup_paths:
136
+ print(f" Backup: {backup}")
137
+ if result.status == ResultStatus.ERROR:
138
+ error_count += 1
139
+
140
+ if not quiet:
141
+ total = sum(1 for a in agents if a.detect())
142
+ print()
143
+ print(
144
+ f"Done. {handled} of {total} detected agents handled. "
145
+ f"{error_count} error{'s' if error_count != 1 else ''}."
146
+ )
147
+ return EXIT_PARTIAL if error_count > 0 else EXIT_OK
148
+
149
+
150
+ def _build_ctx(
151
+ args: argparse.Namespace,
152
+ base_url: str,
153
+ api_key: str,
154
+ catalog: list[CatalogEntry],
155
+ source: str,
156
+ ) -> ConfigureContext:
157
+ return ConfigureContext(
158
+ base_url=base_url,
159
+ api_key=api_key,
160
+ catalog=catalog,
161
+ catalog_source=source,
162
+ dry_run=args.dry_run,
163
+ )
164
+
165
+
166
+ def main(argv: list[str] | None = None) -> int:
167
+ parser = build_parser()
168
+ args = parser.parse_args(argv)
169
+
170
+ if args.command == "version":
171
+ print(f"substrate-setup {__version__}")
172
+ return EXIT_OK
173
+
174
+ command = args.command or "configure"
175
+ base_url = _resolve_base_url(args.base_url)
176
+
177
+ try:
178
+ api_key = resolve_api_key()
179
+ except (CredentialResolutionError, InvalidKeyFormatError) as exc:
180
+ print(str(exc), file=sys.stderr)
181
+ return EXIT_AUTH
182
+
183
+ try:
184
+ catalog, source = fetch_catalog(base_url=base_url, api_key=api_key)
185
+ except AuthError as exc:
186
+ print(str(exc), file=sys.stderr)
187
+ return EXIT_AUTH
188
+ except CatalogUnavailableError as exc:
189
+ print(str(exc), file=sys.stderr)
190
+ return EXIT_CATALOG
191
+
192
+ if not args.quiet:
193
+ print(f"substrate-setup {__version__}")
194
+ print(f"Fetched catalog ({source}): {len(catalog)} models.")
195
+ print(f"API key resolved (length {len(api_key)} chars).")
196
+ print()
197
+
198
+ agents = _select_agents(args.agents_only)
199
+ ctx = _build_ctx(args, base_url, api_key, catalog, source)
200
+
201
+ if command == "configure":
202
+ return _run_per_agent(agents, ctx, lambda a: a.configure(ctx),
203
+ label="Configuring", quiet=args.quiet)
204
+ if command == "verify":
205
+ return _run_per_agent(agents, ctx, lambda a: a.verify(ctx),
206
+ label="Verifying", quiet=args.quiet)
207
+ if command == "remove":
208
+ return _run_per_agent(agents, ctx, lambda a: a.remove(ctx),
209
+ label="Removing", quiet=args.quiet)
210
+ parser.print_help()
211
+ return EXIT_BAD_FLAGS
@@ -0,0 +1,82 @@
1
+ """Credential resolution: env > config file > interactive prompt."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import sys
7
+ from collections.abc import Callable
8
+ from getpass import getpass
9
+ from pathlib import Path
10
+
11
+ try:
12
+ import tomllib
13
+ except ImportError: # pragma: no cover - Python <3.11 fallback
14
+ import tomli as tomllib # type: ignore[no-redef]
15
+
16
+ KEY_REGEX = re.compile(r"^sk-substrate-[A-Za-z0-9_-]{20,}$")
17
+ ENV_VAR = "SUBSTRATE_API_KEY"
18
+
19
+
20
+ def _default_config_path() -> Path:
21
+ if sys.platform == "win32":
22
+ appdata = os.environ.get("APPDATA") or None
23
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
24
+ return base / "substrate-setup" / "credentials.toml"
25
+ xdg = os.environ.get("XDG_CONFIG_HOME")
26
+ base = Path(xdg) if xdg else Path.home() / ".config"
27
+ return base / "substrate-setup" / "credentials.toml"
28
+
29
+
30
+ class CredentialResolutionError(Exception):
31
+ """No API key found in env, config file, or prompt."""
32
+
33
+
34
+ class InvalidKeyFormatError(Exception):
35
+ """The resolved value is not the shape of a Substrate API key."""
36
+
37
+
38
+ def _prompt(question: str) -> str:
39
+ return getpass(question)
40
+
41
+
42
+ def resolve_api_key(
43
+ *,
44
+ config_path: Path | None = None,
45
+ prompt_fn: Callable[[str], str] = _prompt,
46
+ ) -> str:
47
+ """Resolve a Substrate API key. Validate format. Return it.
48
+
49
+ Resolution order: env var → config file → interactive prompt.
50
+ Raises:
51
+ InvalidKeyFormatError: a candidate was found but its shape is wrong.
52
+ CredentialResolutionError: no candidate found at any layer.
53
+ """
54
+ config_path = config_path if config_path is not None else _default_config_path()
55
+
56
+ candidate: str | None = os.environ.get(ENV_VAR)
57
+ if not candidate and config_path.exists():
58
+ try:
59
+ payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
60
+ except tomllib.TOMLDecodeError as exc:
61
+ raise CredentialResolutionError(
62
+ f"{config_path} is not valid TOML: {exc}"
63
+ ) from exc
64
+ candidate = payload.get("api_key")
65
+ if candidate is not None and not isinstance(candidate, str):
66
+ raise CredentialResolutionError(
67
+ f"{config_path}: 'api_key' must be a string, "
68
+ f"got {type(candidate).__name__}."
69
+ )
70
+ if not candidate:
71
+ candidate = prompt_fn("Substrate API key (sk-substrate-...): ").strip()
72
+ if not candidate:
73
+ raise CredentialResolutionError(
74
+ "No API key found. Set SUBSTRATE_API_KEY, write "
75
+ f"{config_path}, or paste when prompted."
76
+ )
77
+ if not KEY_REGEX.match(candidate):
78
+ raise InvalidKeyFormatError(
79
+ "That doesn't look like a Substrate API key — expected "
80
+ "sk-substrate-... followed by at least 20 characters."
81
+ )
82
+ return candidate