substrate-setup 0.2.0__tar.gz → 0.2.1__tar.gz
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-0.2.0 → substrate_setup-0.2.1}/PKG-INFO +1 -1
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/pyproject.toml +1 -1
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/__init__.py +1 -1
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/aider.py +37 -7
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/cursor.py +5 -5
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/hermes.py +50 -13
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/cli.py +19 -5
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/credentials.py +1 -1
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/test_aider.py +54 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/test_hermes.py +69 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_cli.py +57 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/uv.lock +1 -1
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/.gitignore +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/README.md +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/scripts/lint_no_app_import.sh +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/scripts/regenerate_fallback_catalog.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/__main__.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/__init__.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/base.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/continue_dev.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/backup.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/catalog.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/data/fallback_catalog.json +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/markers.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/__init__.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/__init__.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/test_continue_dev.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/test_cursor.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/conftest.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_backup.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_catalog.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_credentials.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_e2e.py +0 -0
- {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_markers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: substrate-setup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: One-shot local configurator for coding agents against a Substrate gateway
|
|
5
5
|
Project-URL: Homepage, https://github.com/FrankXiaA/substrate-solutions
|
|
6
6
|
Project-URL: Source, https://github.com/FrankXiaA/substrate-solutions/tree/main/substrate-api/substrate_setup
|
|
@@ -120,6 +120,30 @@ def _user_has_meaningful_keys(payload: dict[str, Any]) -> bool:
|
|
|
120
120
|
return False
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
def _existing_matches_substrate_shape(
|
|
124
|
+
payload: dict[str, Any], ctx: ConfigureContext
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""True if existing top-level values look substrate-shaped already.
|
|
127
|
+
|
|
128
|
+
Specifically:
|
|
129
|
+
- openai-api-base matches ctx.base_url
|
|
130
|
+
- openai-api-key starts with "sk-substrate-" (the user may have
|
|
131
|
+
rotated keys since, so we don't require an exact match)
|
|
132
|
+
|
|
133
|
+
Lets us silently take ownership of configs that were either manually
|
|
134
|
+
configured to match our schema or written by an earlier substrate-setup
|
|
135
|
+
version that didn't drop the marker. Without this auto-claim, every
|
|
136
|
+
user upgrading from 0.1 (or who manually fixed their config) sees a
|
|
137
|
+
refusal ERROR on first 0.2.x run.
|
|
138
|
+
"""
|
|
139
|
+
if payload.get("openai-api-base") != ctx.base_url:
|
|
140
|
+
return False
|
|
141
|
+
api_key = payload.get("openai-api-key")
|
|
142
|
+
if not isinstance(api_key, str) or not api_key.startswith("sk-substrate-"):
|
|
143
|
+
return False
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
123
147
|
def _pick_default_model(catalog: list[Any]) -> str:
|
|
124
148
|
if not catalog:
|
|
125
149
|
raise ValueError("Cannot pick default model from empty catalog")
|
|
@@ -140,13 +164,19 @@ class AiderAgent(Agent):
|
|
|
140
164
|
payload = _load_yaml(conf)
|
|
141
165
|
|
|
142
166
|
if not _has_substrate_marker(payload) and _user_has_meaningful_keys(payload):
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
167
|
+
if _existing_matches_substrate_shape(payload, ctx):
|
|
168
|
+
# Shape already matches us -- silently take ownership.
|
|
169
|
+
# Fall through to the normal write below, which adds the
|
|
170
|
+
# marker and refreshes our 3 keys.
|
|
171
|
+
pass
|
|
172
|
+
else:
|
|
173
|
+
return AgentResult(
|
|
174
|
+
ResultStatus.ERROR,
|
|
175
|
+
"Aider ~/.aider.conf.yml has user-owned openai-api-base / "
|
|
176
|
+
"openai-api-key / model keys but no substrate-setup marker; "
|
|
177
|
+
"refused to overwrite. To take over, remove those keys (or "
|
|
178
|
+
"the whole file) and re-run substrate-setup.",
|
|
179
|
+
)
|
|
150
180
|
|
|
151
181
|
if conf.exists():
|
|
152
182
|
backup = backup_once(conf)
|
|
@@ -48,15 +48,15 @@ class CursorAgent(Agent):
|
|
|
48
48
|
"Cursor detected. Cursor stores its API config in secure OS storage,\n"
|
|
49
49
|
"so substrate-setup cannot write it for you. Open Cursor and:\n"
|
|
50
50
|
"\n"
|
|
51
|
-
" 1. Settings (
|
|
51
|
+
" 1. Settings (Cmd/Ctrl-,) -> Models\n"
|
|
52
52
|
" 2. Toggle \"Override OpenAI Base URL\" ON\n"
|
|
53
53
|
f" 3. Paste this Base URL: {ctx.base_url}\n"
|
|
54
54
|
f" 4. Paste this API Key: {ctx.api_key}\n"
|
|
55
55
|
" 5. Click \"Verify\"\n"
|
|
56
|
-
" 6. Add these models (Settings
|
|
56
|
+
" 6. Add these models (Settings -> Models -> \"Add Model\"), one at a time:\n"
|
|
57
57
|
f"{bullets}\n"
|
|
58
58
|
"\n"
|
|
59
|
-
"WARNING: enabling \"Override OpenAI Base URL\" is global
|
|
59
|
+
"WARNING: enabling \"Override OpenAI Base URL\" is global -- it will also\n"
|
|
60
60
|
"route Cursor's first-party Composer / Tab / Apply features through\n"
|
|
61
61
|
"Substrate, which may not work as expected. This is a Cursor limitation,\n"
|
|
62
62
|
"not a substrate-setup issue."
|
|
@@ -68,7 +68,7 @@ class CursorAgent(Agent):
|
|
|
68
68
|
|
|
69
69
|
def verify(self, ctx: ConfigureContext) -> AgentResult:
|
|
70
70
|
print(
|
|
71
|
-
"Cursor: cannot verify automatically
|
|
71
|
+
"Cursor: cannot verify automatically -- config lives in Cursor's secure storage."
|
|
72
72
|
)
|
|
73
73
|
return AgentResult(
|
|
74
74
|
ResultStatus.PRINT_ONLY,
|
|
@@ -79,7 +79,7 @@ class CursorAgent(Agent):
|
|
|
79
79
|
print(
|
|
80
80
|
"Cursor: substrate-setup cannot remove your config (it lives in secure\n"
|
|
81
81
|
"OS storage). To clean up, open Cursor and:\n"
|
|
82
|
-
" 1. Settings (
|
|
82
|
+
" 1. Settings (Cmd/Ctrl-,) -> Models\n"
|
|
83
83
|
" 2. Toggle \"Override OpenAI Base URL\" OFF\n"
|
|
84
84
|
" 3. Clear the OpenAI API Key field\n"
|
|
85
85
|
" 4. Remove the Substrate-added models from the model list."
|
|
@@ -195,6 +195,33 @@ def _user_has_meaningful_model_block(payload: dict[str, Any]) -> bool:
|
|
|
195
195
|
return False
|
|
196
196
|
|
|
197
197
|
|
|
198
|
+
def _existing_matches_substrate_shape(
|
|
199
|
+
model: dict[str, Any], ctx: ConfigureContext
|
|
200
|
+
) -> bool:
|
|
201
|
+
"""True if existing model.* values look substrate-shaped already.
|
|
202
|
+
|
|
203
|
+
Specifically:
|
|
204
|
+
- provider == "custom"
|
|
205
|
+
- base_url matches ctx.base_url
|
|
206
|
+
- api_key starts with "sk-substrate-" (we don't require exact match --
|
|
207
|
+
the user may have rotated keys since)
|
|
208
|
+
|
|
209
|
+
This lets us silently take ownership of configs that were either
|
|
210
|
+
manually configured to match our schema or written by an earlier
|
|
211
|
+
substrate-setup version that didn't drop the marker. Without this
|
|
212
|
+
auto-claim, every user upgrading from 0.1 (or who manually fixed
|
|
213
|
+
their config) sees a refusal ERROR on first 0.2.x run.
|
|
214
|
+
"""
|
|
215
|
+
if model.get("provider") != "custom":
|
|
216
|
+
return False
|
|
217
|
+
if model.get("base_url") != ctx.base_url:
|
|
218
|
+
return False
|
|
219
|
+
api_key = model.get("api_key")
|
|
220
|
+
if not isinstance(api_key, str) or not api_key.startswith("sk-substrate-"):
|
|
221
|
+
return False
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
|
|
198
225
|
class HermesAgent(Agent):
|
|
199
226
|
name = "hermes"
|
|
200
227
|
pretty_name = "Hermes"
|
|
@@ -216,19 +243,29 @@ class HermesAgent(Agent):
|
|
|
216
243
|
return err
|
|
217
244
|
assert payload is not None
|
|
218
245
|
|
|
219
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
246
|
+
# Marker absent: decide between auto-claim (shape already looks like
|
|
247
|
+
# ours — safe to take ownership) and refusal (truly user-owned).
|
|
248
|
+
if not _has_substrate_marker(payload):
|
|
249
|
+
existing_model = payload.get("model") or {}
|
|
250
|
+
if not isinstance(existing_model, dict):
|
|
251
|
+
existing_model = {}
|
|
252
|
+
if _user_has_meaningful_model_block(payload):
|
|
253
|
+
if _existing_matches_substrate_shape(existing_model, ctx):
|
|
254
|
+
notes.append(
|
|
255
|
+
"Hermes config.yaml had substrate-shaped keys but no "
|
|
256
|
+
"marker; auto-claiming ownership."
|
|
257
|
+
)
|
|
258
|
+
# fall through to normal write (which adds the marker)
|
|
259
|
+
else:
|
|
260
|
+
return AgentResult(
|
|
261
|
+
ResultStatus.ERROR,
|
|
262
|
+
"Hermes config.yaml has a user-owned `model:` block "
|
|
263
|
+
"but no substrate-setup marker; refused to overwrite. "
|
|
264
|
+
"To take over, delete the existing `model:` block "
|
|
265
|
+
"(or its provider/api_key/base_url keys) and re-run "
|
|
266
|
+
"substrate-setup.",
|
|
267
|
+
tuple(backups),
|
|
268
|
+
)
|
|
232
269
|
|
|
233
270
|
if cfg_path.exists():
|
|
234
271
|
backup = backup_once(cfg_path)
|
|
@@ -103,12 +103,12 @@ def _run_per_agent(
|
|
|
103
103
|
) -> int:
|
|
104
104
|
"""Execute ``method`` on each detected agent. Print results. Return exit code."""
|
|
105
105
|
if not quiet:
|
|
106
|
-
print(f"{label} agents
|
|
106
|
+
print(f"{label} agents ...")
|
|
107
107
|
print("Detected agents:")
|
|
108
108
|
for a in agents:
|
|
109
109
|
detected = a.detect()
|
|
110
110
|
tag = "[print-only handler]" if a.name == "cursor" else ""
|
|
111
|
-
mark = "
|
|
111
|
+
mark = "[ok]" if detected else "[--]"
|
|
112
112
|
note = f"({detected})" if detected else "not installed"
|
|
113
113
|
print(f" {mark} {a.pretty_name:<12} {note} {tag}")
|
|
114
114
|
print()
|
|
@@ -127,11 +127,11 @@ def _run_per_agent(
|
|
|
127
127
|
if not quiet:
|
|
128
128
|
prefix = f"{label} {a.pretty_name:<10}"
|
|
129
129
|
if result.status == ResultStatus.ERROR:
|
|
130
|
-
print(f"{prefix}
|
|
130
|
+
print(f"{prefix} ... ERROR: {result.message}")
|
|
131
131
|
elif result.status == ResultStatus.PRINT_ONLY:
|
|
132
|
-
print(f"{prefix}
|
|
132
|
+
print(f"{prefix} ... printed walkthrough above.")
|
|
133
133
|
else:
|
|
134
|
-
print(f"{prefix}
|
|
134
|
+
print(f"{prefix} ... {result.message}")
|
|
135
135
|
for backup in result.backup_paths:
|
|
136
136
|
print(f" Backup: {backup}")
|
|
137
137
|
if result.status == ResultStatus.ERROR:
|
|
@@ -164,6 +164,20 @@ def _build_ctx(
|
|
|
164
164
|
|
|
165
165
|
|
|
166
166
|
def main(argv: list[str] | None = None) -> int:
|
|
167
|
+
# Safety net for non-ASCII stdout on Windows GBK-locale consoles
|
|
168
|
+
# (and any other narrow legacy encoding). Without this, printing a
|
|
169
|
+
# model id or error message that happens to contain a non-encodable
|
|
170
|
+
# glyph crashes the whole CLI with UnicodeEncodeError. We prefer
|
|
171
|
+
# UTF-8 with ``errors="replace"`` so a stray glyph degrades to ``?``
|
|
172
|
+
# rather than killing the run. ``reconfigure`` is only available on
|
|
173
|
+
# text streams that wrap a buffer — guard with ``hasattr``.
|
|
174
|
+
for stream in (sys.stdout, sys.stderr):
|
|
175
|
+
if hasattr(stream, "reconfigure"):
|
|
176
|
+
try:
|
|
177
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
178
|
+
except (AttributeError, OSError):
|
|
179
|
+
pass
|
|
180
|
+
|
|
167
181
|
parser = build_parser()
|
|
168
182
|
args = parser.parse_args(argv)
|
|
169
183
|
|
|
@@ -76,7 +76,7 @@ def resolve_api_key(
|
|
|
76
76
|
)
|
|
77
77
|
if not KEY_REGEX.match(candidate):
|
|
78
78
|
raise InvalidKeyFormatError(
|
|
79
|
-
"That doesn't look like a Substrate API key
|
|
79
|
+
"That doesn't look like a Substrate API key -- expected "
|
|
80
80
|
"sk-substrate-... followed by at least 20 characters."
|
|
81
81
|
)
|
|
82
82
|
return candidate
|
|
@@ -92,6 +92,60 @@ def test_configure_preserves_user_aider_conf_keys(home_dir):
|
|
|
92
92
|
assert conf_data["openai-api-base"] == "https://gw.example/v1"
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def test_auto_claim_when_shape_matches_unmarked(home_dir):
|
|
96
|
+
"""If openai-api-base == ctx.base_url and openai-api-key starts with
|
|
97
|
+
sk-substrate- but the marker is absent, configure() should SUCCEED,
|
|
98
|
+
write the marker, and keep/refresh the keys. This is the
|
|
99
|
+
upgrade-from-0.1 / manually-fixed case; refusing here is friction
|
|
100
|
+
with no safety benefit.
|
|
101
|
+
"""
|
|
102
|
+
(home_dir / ".aider.conf.yml").write_text(
|
|
103
|
+
"openai-api-base: https://gw.example/v1\n"
|
|
104
|
+
"openai-api-key: sk-substrate-prior-key-shaped-like-substrate\n"
|
|
105
|
+
"model: anthropic/claude-sonnet-4-6\n",
|
|
106
|
+
encoding="utf-8",
|
|
107
|
+
)
|
|
108
|
+
result = AiderAgent().configure(_ctx())
|
|
109
|
+
assert result.status == ResultStatus.SUCCESS
|
|
110
|
+
|
|
111
|
+
conf_data = _yaml().load(
|
|
112
|
+
(home_dir / ".aider.conf.yml").read_text(encoding="utf-8")
|
|
113
|
+
)
|
|
114
|
+
# Marker now present.
|
|
115
|
+
assert conf_data["_substrate_setup_managed_keys"] == [
|
|
116
|
+
"openai-api-base",
|
|
117
|
+
"openai-api-key",
|
|
118
|
+
"model",
|
|
119
|
+
]
|
|
120
|
+
# Keys refreshed to ctx values.
|
|
121
|
+
assert conf_data["openai-api-base"] == "https://gw.example/v1"
|
|
122
|
+
assert conf_data["openai-api-key"] == VALID_KEY
|
|
123
|
+
assert conf_data["model"] == "anthropic/claude-sonnet-4-6"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_still_refuses_truly_user_owned(home_dir):
|
|
127
|
+
"""Marker absent + shape does NOT look substrate (api.openai.com
|
|
128
|
+
base_url, non-sk-substrate key) -> still ERROR + no write.
|
|
129
|
+
"""
|
|
130
|
+
conf = home_dir / ".aider.conf.yml"
|
|
131
|
+
conf.write_text(
|
|
132
|
+
"openai-api-base: https://api.openai.com/v1\n"
|
|
133
|
+
"openai-api-key: sk-real-openai-key-not-substrate-shaped\n"
|
|
134
|
+
"model: openai/gpt-4o\n",
|
|
135
|
+
encoding="utf-8",
|
|
136
|
+
)
|
|
137
|
+
before = conf.read_bytes()
|
|
138
|
+
|
|
139
|
+
result = AiderAgent().configure(_ctx())
|
|
140
|
+
assert result.status == ResultStatus.ERROR
|
|
141
|
+
assert (
|
|
142
|
+
"user-owned" in result.message.lower()
|
|
143
|
+
or "refused" in result.message.lower()
|
|
144
|
+
)
|
|
145
|
+
# File untouched.
|
|
146
|
+
assert conf.read_bytes() == before
|
|
147
|
+
|
|
148
|
+
|
|
95
149
|
def test_refuses_to_overwrite_user_keys_without_marker(home_dir):
|
|
96
150
|
"""If marker absent AND any of our 3 keys are user-set, refuse."""
|
|
97
151
|
(home_dir / ".aider.conf.yml").write_text(
|
|
@@ -179,6 +179,75 @@ _substrate_setup_managed_keys:
|
|
|
179
179
|
assert payload["tools"]["enable_web_search"] is False
|
|
180
180
|
|
|
181
181
|
|
|
182
|
+
def test_auto_claim_when_shape_matches_unmarked(home_dir):
|
|
183
|
+
"""If model.* already looks substrate-shaped (provider=custom,
|
|
184
|
+
base_url == ctx.base_url, api_key startswith sk-substrate-) but the
|
|
185
|
+
marker is absent, configure() should SUCCEED, write the marker, and
|
|
186
|
+
keep/refresh the keys. This is the upgrade-from-0.1 / manually-fixed
|
|
187
|
+
case Frank hit; the prior refusal was UX friction with no safety
|
|
188
|
+
benefit (we'd be overwriting our own shape with our own shape).
|
|
189
|
+
"""
|
|
190
|
+
hermes_dir = home_dir / ".hermes"
|
|
191
|
+
hermes_dir.mkdir()
|
|
192
|
+
# Substrate-shaped values, BUT no _substrate_setup_managed_keys marker.
|
|
193
|
+
(hermes_dir / "config.yaml").write_text(
|
|
194
|
+
"""\
|
|
195
|
+
model:
|
|
196
|
+
default: "anthropic/claude-sonnet-4-6"
|
|
197
|
+
provider: "custom"
|
|
198
|
+
api_key: "sk-substrate-prior-key-shaped-like-substrate"
|
|
199
|
+
base_url: "https://gw.example/v1"
|
|
200
|
+
""",
|
|
201
|
+
encoding="utf-8",
|
|
202
|
+
)
|
|
203
|
+
result = HermesAgent().configure(_ctx())
|
|
204
|
+
assert result.status == ResultStatus.SUCCESS
|
|
205
|
+
|
|
206
|
+
payload = _yaml().load(
|
|
207
|
+
(hermes_dir / "config.yaml").read_text(encoding="utf-8")
|
|
208
|
+
)
|
|
209
|
+
# Marker now present.
|
|
210
|
+
assert payload["_substrate_setup_managed_keys"] == [
|
|
211
|
+
"model.default",
|
|
212
|
+
"model.provider",
|
|
213
|
+
"model.api_key",
|
|
214
|
+
"model.base_url",
|
|
215
|
+
]
|
|
216
|
+
# Keys present (api_key refreshed to ctx.api_key).
|
|
217
|
+
model = payload["model"]
|
|
218
|
+
assert model["provider"] == "custom"
|
|
219
|
+
assert model["base_url"] == "https://gw.example/v1"
|
|
220
|
+
assert model["api_key"] == VALID_KEY
|
|
221
|
+
assert model["default"] == "anthropic/claude-sonnet-4-6"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_still_refuses_truly_user_owned(home_dir):
|
|
225
|
+
"""Marker absent + shape does NOT look substrate (openai provider,
|
|
226
|
+
api.openai.com base_url, non-sk-substrate key) -> still ERROR + no
|
|
227
|
+
write. Belt-and-suspenders for the auto-claim split.
|
|
228
|
+
"""
|
|
229
|
+
hermes_dir = home_dir / ".hermes"
|
|
230
|
+
hermes_dir.mkdir()
|
|
231
|
+
cfg = hermes_dir / "config.yaml"
|
|
232
|
+
cfg.write_text(
|
|
233
|
+
"""\
|
|
234
|
+
model:
|
|
235
|
+
default: "gpt-4o"
|
|
236
|
+
provider: "openai"
|
|
237
|
+
api_key: "sk-real-openai-key-not-substrate-shaped"
|
|
238
|
+
base_url: "https://api.openai.com/v1"
|
|
239
|
+
""",
|
|
240
|
+
encoding="utf-8",
|
|
241
|
+
)
|
|
242
|
+
before = cfg.read_bytes()
|
|
243
|
+
|
|
244
|
+
result = HermesAgent().configure(_ctx())
|
|
245
|
+
assert result.status == ResultStatus.ERROR
|
|
246
|
+
assert "user-owned" in result.message.lower() or "refused" in result.message.lower()
|
|
247
|
+
# File untouched.
|
|
248
|
+
assert cfg.read_bytes() == before
|
|
249
|
+
|
|
250
|
+
|
|
182
251
|
def test_refuses_to_overwrite_user_model_without_marker(home_dir):
|
|
183
252
|
"""If marker absent AND model.provider is user-set non-substrate value, refuse."""
|
|
184
253
|
hermes_dir = home_dir / ".hermes"
|
|
@@ -102,6 +102,63 @@ def test_agents_only_filters(stub_env, httpx_mock, capsys, monkeypatch):
|
|
|
102
102
|
assert code == EXIT_OK
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def test_main_reconfigures_stdout_utf8_errors_replace(monkeypatch):
|
|
106
|
+
"""main() must call ``reconfigure(encoding='utf-8', errors='replace')`` on
|
|
107
|
+
stdout/stderr at entry — without this, printing any non-ASCII glyph
|
|
108
|
+
(e.g. a model id or upstream error message) crashes on Windows GBK
|
|
109
|
+
consoles with UnicodeEncodeError. Affects every China beta user.
|
|
110
|
+
"""
|
|
111
|
+
import sys as _sys
|
|
112
|
+
|
|
113
|
+
captured: list[tuple[str, dict[str, object]]] = []
|
|
114
|
+
|
|
115
|
+
class _FakeStream:
|
|
116
|
+
def reconfigure(self, **kwargs: object) -> None:
|
|
117
|
+
captured.append(("reconfigure", kwargs))
|
|
118
|
+
|
|
119
|
+
def write(self, _data: str) -> int:
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
def flush(self) -> None:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
fake_out = _FakeStream()
|
|
126
|
+
fake_err = _FakeStream()
|
|
127
|
+
monkeypatch.setattr(_sys, "stdout", fake_out)
|
|
128
|
+
monkeypatch.setattr(_sys, "stderr", fake_err)
|
|
129
|
+
|
|
130
|
+
# ``version`` is the cheapest path — it doesn't touch HTTP or files.
|
|
131
|
+
main(["version"])
|
|
132
|
+
|
|
133
|
+
# Both streams must have been reconfigured with utf-8 + errors=replace.
|
|
134
|
+
kwargs_seen = [c[1] for c in captured if c[0] == "reconfigure"]
|
|
135
|
+
assert len(kwargs_seen) == 2, f"expected 2 reconfigure calls, got {captured}"
|
|
136
|
+
for kw in kwargs_seen:
|
|
137
|
+
assert kw == {"encoding": "utf-8", "errors": "replace"}, kw
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_status_glyphs_are_ascii_only(stub_env, httpx_mock, capsys):
|
|
141
|
+
"""The detected/not-installed status line must not print any non-ASCII
|
|
142
|
+
char (the old ``✓``/``·`` glyphs and ``…`` ellipsis crashed GBK consoles).
|
|
143
|
+
"""
|
|
144
|
+
httpx_mock.add_response(
|
|
145
|
+
url="https://gw.example/v1/models",
|
|
146
|
+
json={"object": "list", "data": [
|
|
147
|
+
{"id": "openai/gpt-5.5", "object": "model", "owned_by": "openai",
|
|
148
|
+
"display_name": "GPT-5.5", "description": "x"},
|
|
149
|
+
]},
|
|
150
|
+
)
|
|
151
|
+
code = main(["configure", "--base-url", "https://gw.example"])
|
|
152
|
+
assert code == EXIT_OK
|
|
153
|
+
out = capsys.readouterr().out
|
|
154
|
+
# The whole orchestrator output must be ASCII-clean now.
|
|
155
|
+
non_ascii = [ch for ch in out if ord(ch) > 127]
|
|
156
|
+
assert not non_ascii, (
|
|
157
|
+
f"non-ASCII chars leaked into CLI output: "
|
|
158
|
+
f"{sorted(set(non_ascii))!r}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
105
162
|
def test_dry_run_does_not_write(stub_env, httpx_mock, capsys):
|
|
106
163
|
httpx_mock.add_response(
|
|
107
164
|
url="https://gw.example/v1/models",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|