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.
Files changed (34) hide show
  1. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/PKG-INFO +1 -1
  2. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/pyproject.toml +1 -1
  3. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/__init__.py +1 -1
  4. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/aider.py +37 -7
  5. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/cursor.py +5 -5
  6. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/hermes.py +50 -13
  7. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/cli.py +19 -5
  8. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/credentials.py +1 -1
  9. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/test_aider.py +54 -0
  10. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/test_hermes.py +69 -0
  11. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_cli.py +57 -0
  12. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/uv.lock +1 -1
  13. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/.gitignore +0 -0
  14. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/README.md +0 -0
  15. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/scripts/lint_no_app_import.sh +0 -0
  16. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/scripts/regenerate_fallback_catalog.py +0 -0
  17. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/__main__.py +0 -0
  18. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/__init__.py +0 -0
  19. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/base.py +0 -0
  20. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/agents/continue_dev.py +0 -0
  21. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/backup.py +0 -0
  22. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/catalog.py +0 -0
  23. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/data/fallback_catalog.json +0 -0
  24. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/substrate_setup/markers.py +0 -0
  25. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/__init__.py +0 -0
  26. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/__init__.py +0 -0
  27. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/test_continue_dev.py +0 -0
  28. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/agents/test_cursor.py +0 -0
  29. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/conftest.py +0 -0
  30. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_backup.py +0 -0
  31. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_catalog.py +0 -0
  32. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_credentials.py +0 -0
  33. {substrate_setup-0.2.0 → substrate_setup-0.2.1}/tests/test_e2e.py +0 -0
  34. {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.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "substrate-setup"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "One-shot local configurator for coding agents against a Substrate gateway"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -3,4 +3,4 @@
3
3
  See https://github.com/FrankXiaA/substrate-solutions for the gateway it
4
4
  configures against.
5
5
  """
6
- __version__ = "0.2.0"
6
+ __version__ = "0.2.1"
@@ -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
- return AgentResult(
144
- ResultStatus.ERROR,
145
- "Aider ~/.aider.conf.yml has user-owned openai-api-base / "
146
- "openai-api-key / model keys but no substrate-setup marker; "
147
- "refused to overwrite. To take over, remove those keys (or "
148
- "the whole file) and re-run substrate-setup.",
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 (⌘/Ctrl-,) Models\n"
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 Models \"Add Model\"), one at a time:\n"
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 it will also\n"
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 config lives in Cursor's secure storage."
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 (⌘/Ctrl-,) Models\n"
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
- # Refusal: marker absent AND user has a non-empty model: block we don't own.
220
- if not _has_substrate_marker(payload) and _user_has_meaningful_model_block(payload):
221
- notes.append(
222
- "Hermes config.yaml has a user-owned `model:` block but no "
223
- "substrate-setup marker; refused to overwrite. To take over, "
224
- "delete the existing `model:` block (or its provider/api_key/"
225
- "base_url keys) and re-run substrate-setup."
226
- )
227
- return AgentResult(
228
- ResultStatus.ERROR,
229
- "\n".join(notes),
230
- tuple(backups),
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 = "" if detected else "·"
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} ERROR: {result.message}")
130
+ print(f"{prefix} ... ERROR: {result.message}")
131
131
  elif result.status == ResultStatus.PRINT_ONLY:
132
- print(f"{prefix} printed walkthrough above.")
132
+ print(f"{prefix} ... printed walkthrough above.")
133
133
  else:
134
- print(f"{prefix} {result.message}")
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 expected "
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",
@@ -305,7 +305,7 @@ wheels = [
305
305
 
306
306
  [[package]]
307
307
  name = "substrate-setup"
308
- version = "0.2.0"
308
+ version = "0.2.1"
309
309
  source = { editable = "." }
310
310
  dependencies = [
311
311
  { name = "httpx" },