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.
- substrate_setup/__init__.py +6 -0
- substrate_setup/__main__.py +5 -0
- substrate_setup/agents/__init__.py +20 -0
- substrate_setup/agents/aider.py +250 -0
- substrate_setup/agents/base.py +56 -0
- substrate_setup/agents/continue_dev.py +194 -0
- substrate_setup/agents/cursor.py +90 -0
- substrate_setup/agents/hermes.py +259 -0
- substrate_setup/backup.py +32 -0
- substrate_setup/catalog.py +109 -0
- substrate_setup/cli.py +211 -0
- substrate_setup/credentials.py +82 -0
- substrate_setup/data/fallback_catalog.json +301 -0
- substrate_setup/markers.py +27 -0
- substrate_setup-0.1.0.dist-info/METADATA +27 -0
- substrate_setup-0.1.0.dist-info/RECORD +18 -0
- substrate_setup-0.1.0.dist-info/WHEEL +4 -0
- substrate_setup-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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
|